The Liquid Loop That Adds Three Seconds to Your Shopify Page

Liquid has no query log, so N+1 problems are invisible until the page is slow. Here is how to find them with Server-Timing and how to restructure the loops.

Share
The Liquid Loop That Adds Three Seconds to Your Shopify Page. Abstract shopify illustration in orange and dark grey on debugly.dev

Disclosure: I worked on Shopify themes for four years at Debutify, finishing as CDO. The patterns below come from auditing merchant stores.

The short answer

If your Shopify page takes more than about 600ms of server processing, you probably have a Liquid loop that touches an expensive object once per iteration. Check it:

curl -sI https://yourstore.com/collections/all | grep -i server-timing
server-timing: processing;dur=1840, db;dur=120

processing;dur is Liquid render time in milliseconds. Under 200 is healthy, over 600 needs investigation, over 1500 is usually a loop.

The usual culprits, in order of how often I find them: accessing collection.products or product.variants inside a loop, calling all_products by handle repeatedly, resolving metafields per item, and any nested loop over products inside a loop over collections.

Why this is invisible

In a normal backend you have a query log. Turn it on, count the queries, spot the pattern where one page issues 300 of them.

Liquid gives you nothing. There is no query log, no profiler, no way to instrument what a template is doing internally. Shopify's Liquid objects are lazy: collection.products looks like an array sitting in memory, and it is actually a handle that fetches when accessed. That is a sensible design and it means the cost of an access is completely hidden at the point where you write it.

So you write this:

{% for collection in collections %}
  <h2>{{ collection.title }}</h2>
  <p>{{ collection.products.size }} products</p>
{% endfor %}

It reads like you are checking the length of an array you already have. You are not. Each collection.products access is a fetch, and with forty collections that is forty fetches on every page load.

Finding it

Server-Timing gives you the total

curl -sI https://yourstore.com/products/some-product | grep -i server-timing

That tells you a page is slow but not why. Still, it is the right first measurement, because it separates "the server is slow" from "the browser is slow", and those have entirely different fixes. If processing;dur is 120ms and your page still feels sluggish, stop reading this post and go look at your JavaScript.

Bisect the template

Since there is no profiler, bisection is the practical method. Comment out half your sections, measure, repeat. It feels crude and it takes about five iterations to isolate a section.

For a finer view inside a section, you can use Liquid's {% render %} boundaries and comment out blocks one at a time.

Look for the patterns directly

Faster than bisecting, once you know what to grep for. In your theme code, search for:

  • collection.products appearing anywhere inside a {% for %} block
  • all_products[ inside a loop
  • .metafields. inside a loop
  • Nested {% for %} where the outer loop is over collections or products
  • product.variants inside a loop over products

Any of these is worth a closer look.

The four patterns and their fixes

1. Product count inside a collection loop

Slow:

{% for collection in collections %}
  <a href="{{ collection.url }}">
    {{ collection.title }} ({{ collection.products.size }})
  </a>
{% endfor %}

Every collection.products.size triggers a fetch of that collection's products just to count them.

Fast:

{% for collection in collections %}
  <a href="{{ collection.url }}">
    {{ collection.title }} ({{ collection.all_products_count }})
  </a>
{% endfor %}

all_products_count is a precomputed integer on the collection object. It costs nothing. This one substitution has taken pages from 1.9 seconds to 240ms on stores with large collection lists.

2. Fetching products by handle in a loop

Slow:

{% assign handles = 'shirt-a,shirt-b,shirt-c,shirt-d' | split: ',' %}
{% for handle in handles %}
  {% assign p = all_products[handle] %}
  {% render 'product-card', product: p %}
{% endfor %}

Each all_products[handle] is an individual lookup. Shopify also caps all_products lookups per template, so beyond a certain count they silently return nothing, which produces the delightful bug where the last few products in a list just do not render.

Fast: use a collection instead of a handle list.

{% assign featured = collections['featured-shirts'] %}
{% for product in featured.products limit: 8 %}
  {% render 'product-card', product: product %}
{% endfor %}

One fetch. The collection is also editable by the merchant in admin instead of hardcoded in the theme, which is a second win.

If you genuinely need arbitrary products chosen per section, use a product_list setting in your section schema. The theme editor gives merchants a picker and Shopify resolves the list efficiently.

3. Metafields resolved per item

Slow:

{% for product in collection.products %}
  {% if product.metafields.custom.badge_text != blank %}
    <span class="badge">{{ product.metafields.custom.badge_text }}</span>
  {% endif %}
  {% if product.metafields.custom.eco_certified %}
    <span class="badge">Eco</span>
  {% endif %}
{% endfor %}

With a 50 product collection and two metafield lookups per product you are doing 100 resolutions.

Better: assign once per iteration, so at least you are not re resolving the same field twice.

{% for product in collection.products %}
  {% assign badge = product.metafields.custom.badge_text %}
  {% if badge != blank %}<span class="badge">{{ badge }}</span>{% endif %}
{% endfor %}

Best: for data that drives filtering or badging across a whole collection, move it into tags where it makes sense, since tags come back with the product without an extra resolution. Metafields are the right tool for rich per product content on a product page. They are the wrong tool for a flag you check on fifty cards in a grid.

4. The nested loop

Slow, and I still find this on live stores:

{% for collection in collections %}
  {% for product in collection.products %}
    {% for variant in product.variants %}
      ...
    {% endfor %}
  {% endfor %}
{% endfor %}

Twenty collections, fifty products each, five variants each is 5,000 iterations with fetches at two levels. This is how you get a ten second page.

There is no clever fix. Restructure so the page renders one collection at a time, or paginate, or move the data requirement to the client and fetch it from the Storefront API after first paint.

The paginate rule

paginate limits what Liquid renders and what it fetches:

{% paginate collection.products by 24 %}
  {% for product in collection.products %}
    {% render 'product-card', product: product %}
  {% endfor %}
  {{ paginate | default_pagination }}
{% endpaginate %}

Without it, a collection loop is capped at 50 products by Shopify, which produces a subtler bug: your 200 product collection silently shows 50 and nobody notices for months because the page looks fine.

Note that paginate must wrap the loop. Putting the for outside the paginate block does nothing useful, and I see that mistake regularly.

Section rendering and caching

Two things worth knowing.

Shopify caches rendered sections aggressively, but the cache is invalidated by a lot of things, so do not rely on it to hide an expensive section. Measure with cache disabled by adding a cache busting query parameter.

The Section Rendering API lets you load an expensive section after first paint:

fetch(`${window.location.pathname}?section_id=recommendations`)
  .then(r => r.text())
  .then(html => {
    document.querySelector('#recommendations').innerHTML = html;
  });

This is the right move for product recommendations, recently viewed items, and anything else below the fold that requires a lot of Liquid work. The initial HTML gets to the browser fast and the expensive part arrives afterwards.

Shopify's own product recommendations endpoint at /recommendations/products is built for exactly this and is better than rolling your own related products logic in Liquid.

A worked example

A store I audited had a collection page at 2.4 seconds of processing time. The template had:

  • A sidebar listing all 38 collections with product counts, using collection.products.size
  • A product grid of 50 items, each checking three metafields
  • A "you may also like" section looping all_products over 12 hardcoded handles

Changes made:

  1. collection.products.size to all_products_count in the sidebar
  2. Three metafield checks reduced to one assigned variable plus tag based badging
  3. The recommendations section moved to the Section Rendering API, loaded after paint

Result: processing;dur went from 2410ms to 190ms. No visual change to the page at all.

The sidebar fix alone accounted for roughly 1.4 seconds of it, and it was a one word change.

Prevention

Put Server-Timing in your review checklist. Before merging a theme change, curl the affected template and compare processing time to main. A regression from 180ms to 900ms is obvious when you look and invisible when you do not.

Treat any object access inside a loop as suspicious. In Liquid, property access can be a network call. That is the mental model to hold.

Prefer all_products_count, product.first_available_variant, and other precomputed properties over anything that makes you touch a collection.

Know the limits. 50 products per unpaginated loop, and a per template cap on all_products lookups. Both fail silently, which makes them worse than errors.

Test with a realistic catalogue. A development store with 12 products will never show you this problem. Ask for a copy of the merchant's catalogue size, or generate one.