The Liquid Loops Quietly Killing Your Shopify Collection Pages
A technical teardown of the three Liquid anti-patterns we find on almost every Shopify audit: nested metafield loops, uncached linklist iterations, and unchecked product-tag filters. With...
Open your slowest collection page in Chrome, pop the Network panel, and look at the document row. If the Waiting (TTFB) value sits above 900 ms, the problem is almost never what your speed plugin is yelling about. It is not your hero image. It is not that third-party review widget. It is Liquid, running server-side inside Shopify's renderer, iterating over products and quietly touching metafields, linklists, and tag arrays in ways that turn a 30 ms template into a 1.8 second TTFB.
This post is a teardown of the three anti-patterns we find on almost every theme audit. Each one comes with the code we actually see shipped, the refactored version, and a realistic before/after in milliseconds. The numbers are drawn from Shopify's own engineering writeup on the Theme Inspector and from flame graphs we run on client themes before and after refactor. Where a number is an estimate from field work rather than a public citation, the prose says so.
Why Liquid Render Time Is The Silent Killer
The standard "my Shopify store is slow" advice is image-centric. Compress hero images, lazy load the gallery, defer the review script. This advice is not wrong. It is just pointed at the wrong layer for collection templates. On a product grid rendering 24 to 50 items, the bottleneck is almost always the server putting the HTML together, not the browser painting it. Shopify's storefront is CDN-backed, so once the HTML ships it arrives fast. The delay is the seconds the renderer spends walking your Liquid.
Shopify's engineering team published real numbers on this. A simple loop rendering ten product titles takes under 10 ms. The same loop, once it starts reaching into product attributes like tags, variants, and metafields, jumps to about 16 ms per tile. Scale that to a paginated 50-product grid and you are looking at roughly 800 ms of pure Liquid render time before a single byte has left the server. Add a two-level nested loop anywhere in the card snippet and you add another 55 ms baseline, compounded. That is where your TTFB budget is going.
The Chrome Theme Inspector extension is the only objective way to see this. It produces a flame graph of exactly which snippet, section, and tag consumed which millisecond. Every audit we run starts there. If you have never installed it, stop reading, install it, and run it against your slowest collection. The rest of this article will make sense once you have seen your own flame graph.
Anti-Pattern One: Nested Metafield Loops
This is the single most expensive pattern we find, and it ships in roughly seven of every ten themes we look at. It usually starts innocently. A merchandiser wants a badge on product cards that says "Best Seller" or "New" based on a custom metafield. Then they want to show a short ingredient list on hover, stored as a list-type metafield. The developer, under pressure, writes something that looks like this inside snippets/product-card.liquid:
{% for product in collection.products %}
{% if product.metafields.custom.badge_label != blank %}
<span class="badge">{{ product.metafields.custom.badge_label }}</span>
{% endif %}
{% if product.metafields.custom.ingredients.value %}
<ul class="ingredients">
{% for ingredient in product.metafields.custom.ingredients.value %}
<li>{{ ingredient.name }} ({{ ingredient.metafields.custom.origin }})</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
Every product.metafields.custom.* access is a resolution event. Shopify's renderer is clever enough to batch some of this, but once you nest a second loop over a metaobject list metafield and then reach into that metaobject's own metafields, you have built a 3-level deep dependency chain that gets walked once per product. On a 50-item paginate, we routinely see this pattern add between 400 and 900 ms to Liquid render time, depending on how many metafields are touched and how deep the metaobject tree goes. Those are numbers from our own flame graphs, not a public Shopify benchmark, but they are consistent across the dozen or so audits we ran this quarter.
The fix is two-part. First, hoist every access you can out of the loop. If the metafield is on the shop or collection object, assign it once at the top of the section. Second, inside the loop, reach into the metafield exactly once per product, capture what you need, and do your conditionals against the captured variable. The refactored version looks like this.
{% for product in collection.products %}
{% assign badge = product.metafields.custom.badge_label %}
{% assign ingredients = product.metafields.custom.ingredients.value %}
{% if badge != blank %}
<span class="badge">{{ badge }}</span>
{% endif %}
{% if ingredients != blank and ingredients.size > 0 %}
<ul class="ingredients">
{% for ingredient in ingredients limit: 3 %}
<li>{{ ingredient.name }}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
Two things changed beyond the obvious assign. We removed the nested metafield reach into ingredient.metafields.custom.origin, which was the cost multiplier. If origin must be displayed, denormalise it onto the ingredient metaobject itself so it resolves in the same query. We also added a limit: 3 on the ingredient loop, because the hover card only shows three anyway. Before refactor on a recent client theme, a 48-product grid had a Liquid render time of 1.42 s on the Theme Inspector flame graph. After the refactor described above, the same page rendered in 520 ms. The visible change to the shopper is zero. The TTFB improvement is almost 900 ms.
Anti-Pattern Two: Uncached Linklist Iterations
This one is sneaky because the expensive code does not live on the collection template. It lives in a header or mega-menu snippet that happens to be rendered once per page but walks a tree of links three levels deep. The pattern looks fine in isolation.
<nav class="mega-menu">
{% for link in linklists.main-menu.links %}
<div class="col">
<a href="{{ link.url }}">{{ link.title }}</a>
{% for sublink in link.links %}
<a href="{{ sublink.url }}">{{ sublink.title }}</a>
{% for subsublink in sublink.links %}
<a href="{{ subsublink.url }}">{{ subsublink.title }}</a>
{% endfor %}
{% endfor %}
</div>
{% endfor %}
</nav>
Rendered once, this is cheap enough. The problem is when a theme author, trying to build a "featured category" section inside each product card, decides to pull the same linklist again inside product-card.liquid to show a breadcrumb or a related-category chip. Now linklists.main-menu is resolved 48 times on a collection page, walking three levels each time, because Liquid does not cache repeated object lookups across iterations the way you might hope. Shopify has open issues about this exact behaviour on the Liquid repo, and the product position is that caching is the template author's job.
The poor-man's memoisation Liquid offers is assign and capture. Resolve the linklist once at the top of the section, either into a variable you pass down or into a captured string of pre-rendered HTML, then reuse it. If the relationship between product and menu link is dynamic, still resolve the menu once, then do your lookup against the captured variable.
{% assign main_links = linklists['main-menu'].links %}
{% for product in collection.products %}
{% assign product_type = product.type | handleize %}
{% assign matched_link = blank %}
{% for link in main_links %}
{% if link.object.handle == product_type %}
{% assign matched_link = link %}
{% break %}
{% endif %}
{% endfor %}
{% if matched_link != blank %}
<a class="category-chip" href="{{ matched_link.url }}">{{ matched_link.title }}</a>
{% endif %}
{% endfor %}
The inner loop is still there, but it walks an already-resolved array in memory rather than re-resolving the linklist object. On field audits we have seen this fix alone shave between 120 and 300 ms from TTFB on stores with deep mega-menus. The lower end when the menu has 20 top-level items, the upper end when it has 60 plus nested children.
Anti-Pattern Three: Unchecked for product in collection.products With Filter Logic
This is the one that hurts the most because the fix is architectural, not cosmetic. The anti-pattern is doing filtering work in Liquid that should live in a server-side query or in the URL.
{% paginate collection.products by 24 %}
{% for product in collection.products %}
{% if product.available and product.tags contains 'featured' %}
{% render 'product-card', product: product %}
{% endif %}
{% endfor %}
{% endpaginate %}
This looks harmless. It is not. The paginate tag controls the size of the iteration, but it does not restrict what Shopify fetches in the sense you might expect; more importantly, when you put conditional rendering inside the loop, you end up with pages that visibly display 11 cards instead of 24 because the filter rejected some. Worse, every product in the iteration still pays the tag-lookup cost. product.tags contains 'featured' has to resolve the tags array per product, which Shopify's inspector reports as one of the more expensive attribute accesses after variants and metafields.
The correct move depends on the business goal. If the page is meant to show only featured products, it should be a curated collection with its own handle, or it should use Online Store 2.0 faceted filter URLs so that the filter runs on Shopify's side before the Liquid ever sees the array. If the page is meant to show all products but highlight featured ones, then the conditional should be inside the card's presentation (a ribbon, not a gate), not controlling whether the card renders at all.
{% paginate collection.products by 24 %}
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
{{ paginate | default_pagination }}
{% endpaginate %}
Then inside product-card.liquid, handle availability and featured status with a single tags read captured once per product, not with nested conditionals that each re-resolve. If you need a server-filtered view, link to /collections/your-collection?filter.v.availability=1 and let Shopify do the work before the request hits the template. In practice, removing Liquid-side filter logic from collection templates drops Liquid render time by 150 to 400 ms on grids of 24 to 50 items, based on before-and-after flame graphs we run on audit engagements.
One more thing about pagination worth stating plainly. The by parameter on paginate should match your viewport. We see themes paginating by 50 because the designer wants a long scroll, and this is an expensive decision. Pagination by 16 with a "load more" button backed by the Section Rendering API gives the shopper the same experience and halves first-paint Liquid cost, because the remaining products render in a subsequent fetch-on-demand request.
What You Should Do This Afternoon
If you have read this far and recognised your own theme in at least one of these patterns, you have a clear first hour of work ahead. Install the Shopify Theme Inspector for Chrome. Log in as theme editor, open your slowest collection page, hit the flame-graph button, and sort by render time descending. Look for three things. A snippet that shows up once in the flame graph but whose total self-time is in the hundreds of milliseconds, which usually means nested metafield access inside a card. A linklist resolution that appears more than once, which means you are re-resolving a menu inside a loop. And a long flat bar for the product grid itself that is out of proportion to the number of products on screen, which usually means tag- or availability-based filtering is happening in Liquid.
Fix what you find in that order. Metafield hoisting is the single biggest lever and the easiest to apply safely because it is a pure refactor with no behaviour change. Linklist memoisation is next and is also low-risk. Moving filter logic out of Liquid is the highest-impact change but also the one most likely to require design and merch conversations, because you may be changing what the shopper sees on a given URL.
Run the inspector again after each change. If you do not see the numbers move, you fixed the wrong thing, which is fine: revert and move to the next candidate. The flame graph does not lie.
When It Is Worth Bringing Someone In
We run theme code audits for clients who have a working Shopify store but suspect they are leaving speed on the table. The engagement is small and focused. We install the Theme Inspector on a staging duplicate, run flame graphs on your three highest-traffic templates (usually home, a collection, and the product template), and ship back a report that names the specific snippets costing the specific milliseconds, with before-and-after code for each fix. If you want us to do the refactor, we open a pull request against your theme repo with the changes isolated per commit so your team can review each fix independently. The goal is not to rewrite your theme. It is to remove the three or four specific Liquid patterns that are costing you a second of TTFB on the pages that convert.
If you already have a developer in-house and you just want the report so they can do the work, that is also fine and is usually the faster route. Either way, the first step is the flame graph. If you want us to run that first read, we are happy to, and if the report comes back clean we will tell you so and point you back at images and third-party scripts, which is where the remaining 20 percent of Shopify speed work actually lives.
Liquid render time is the silent killer on collection pages because it hides inside TTFB, which most speed tools report as a single number without attribution. Once you have a flame graph, the attribution problem is solved and the fixes are mostly mechanical. Nested metafield loops, uncached linklists, and Liquid-side filter logic account for the majority of the damage we see. Fix those three, re-measure, and you will almost certainly find your collection pages are faster than your competitors' without having touched a single image or script tag.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
Shopify Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss ecom for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.

