Why render Is Faster Than include, and Its Real Cost
Shopify deprecated include for a reason that is about scope, not syntax. The performance difference is real and the migration breaks things in a predictable…
include has been deprecated since 2019 and there are still production themes full of it. The usual reason given for migrating is "Shopify says so", which is not an argument that motivates anybody to touch a working theme.
The real reason is scope, and scope is why one of them can be optimised and the other cannot.
What the difference actually is
include gives the snippet access to the entire parent scope. Every variable defined anywhere before the call is visible inside, and any assignment the snippet makes leaks back out.
render gives the snippet an isolated scope. It sees only what you explicitly pass, plus global objects like shop, cart and settings. Nothing it assigns escapes.
{%- comment -%} include: shared scope, surprising both ways {%- endcomment -%}
{%- assign heading = "Featured" -%}
{% include 'product-card' %}
{{ heading }}
{%- comment -%} may no longer be "Featured": the snippet could have reassigned it {%- endcomment -%}
{%- comment -%} render: isolated, predictable {%- endcomment -%}
{% render 'product-card', product: product, heading: heading %}
{{ heading }}
{%- comment -%} still "Featured", guaranteed {%- endcomment -%}
Tested against the current Liquid runtime, Dawn 15.
Why isolation makes it faster
Because the parser can know, before rendering, exactly what a render snippet depends on.
With include, the snippet's behaviour depends on the entire mutable state of the parent at the moment of the call. That state can differ on every iteration of a loop, so the output cannot be reused and the whole thing must be re-evaluated each time. The renderer also has to carry the full variable environment into every call.
With render, the inputs are declared. The same inputs produce the same output, which makes caching and parallel evaluation possible, and Shopify's renderer takes advantage of that.
There is a second, larger effect in loops. render supports the for form:
{%- comment -%} one render call, iterating internally {%- endcomment -%}
{% render 'product-card' for collection.products as product %}
{%- comment -%} versus a call per iteration {%- endcomment -%}
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
The for form sets up the snippet once and iterates inside it, rather than re-entering the render machinery per item. On a 50 product collection page the difference is measurable in server response time, which is the metric Shopify's speed score weights most heavily and the one you have the least other leverage over.
Measuring it rather than believing me
Liquid gives you a render time via Server-Timing, and Shopify exposes it on the response:
curl -sS -o /dev/null -D - "https://yourstore.com/collections/all" \
| grep -i server-timing
server-timing: db;dur=48, parse;dur=12, render;dur=194, total;dur=261
render is your Liquid. That is the number this post is about, and it is the only part of the server response you directly control. Run it ten times and take the median, because a cold render is not representative.
For a controlled comparison, duplicate a template, convert one copy, and hit both:
for t in old new; do
echo -n "$t: "
for i in $(seq 10); do
curl -sS -o /dev/null -D - "https://yourstore.com/collections/all?view=$t" \
| grep -oi 'render;dur=[0-9.]*' | cut -d= -f2
done | sort -n | awk '{a[NR]=$1} END{print a[int(NR/2)]}'
done
On the collection template I converted most recently, median render went from 194 ms to 141 ms. Not transformative, but it is 53 ms on every collection page view for a change with no visual output, and it compounds with everything else.
How the migration breaks, predictably
Converting is mechanical until it is not. The failures cluster into four shapes.
Silent nils from undeclared variables. The snippet used product because the parent happened to have one in scope. Under render it is nil, and Liquid renders nil as an empty string rather than raising. Your product card simply goes blank, on some pages, with no error anywhere. This is the Liquid fails silently problem in its most annoying form.
Loop variables. forloop does not cross into a rendered snippet. If the snippet used forloop.index, pass it: {% render 'card', product: product, index: forloop.index %}.
Assignments that were doing real work. A snippet that computed a total and assigned it for the parent to print will now compute it into the void. Restructure so the parent computes and passes down, which is better code anyway.
Recursive includes. include allowed a snippet to include itself, and menus were built this way. render forbids it, and the replacement is the for form or an explicit depth limited structure. This is the one conversion that requires actual thought.
A grep that finds the risky ones before you start:
# every include, with the snippet name
grep -rn "{%[- ]*include" sections/ snippets/ templates/ layout/
# snippets that assign, which are the ones that may leak on purpose
grep -rln "{%[- ]*assign" snippets/
The intersection of those two lists is where your migration will hurt.
A conversion order that does not break the store
Start with leaf snippets. Anything that only prints, assigns nothing, and is called from one place. These are safe and they build confidence.
Convert one template at a time, not the whole theme. Duplicate the template, convert the copy, compare rendered HTML byte for byte with curl and diff. Identical output means the conversion is correct.
curl -sS "https://yourstore.com/collections/all" > /tmp/old.html
curl -sS "https://yourstore.com/collections/all?view=new" > /tmp/new.html
diff <(sed 's/view=new//' /tmp/new.html) /tmp/old.html
Do the product card last. It is the most called snippet, it benefits most, and it has the most implicit dependencies. Do it when you know the theme.
Keep the for form in mind. Every {% for %} wrapping a single {% render %} is a free conversion to {% render ... for ... %}.
Is it worth doing at all
If your theme renders in 60 ms, no. Go and look at your apps and your images instead, because that is where the time is.
If your collection templates are rendering in 200 ms or more and you have deep snippet nesting, then yes, and the scope isolation is worth more than the milliseconds. Debugging a theme where any snippet can silently mutate any variable is a genuinely worse experience, and I have lost hours to exactly that. The performance improvement is the excuse. Predictable scope is the reward.
Related reading: How to Tell Which App Is Actually Slowing Your Store Down explores a connected angle.
Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud