Sub-2s TTFB on Shopify: When Liquid Loops Cost You Real Money (2026)

I diagnosed a Shopify Plus storefront last quarter sitting at p75 TTFB 2.4 seconds on mobile. The merchant assumed Shopify’s CDN was slow. It wasn’t. The collection template had a nested Liquid for loop running over all_products with metafield reads inside the inner loop. 62,000 iterations on every page load. Single PR rewrite, TTFB dropped to 580ms in 24 hours. The 4 patterns below cover every Liquid-side TTFB issue I see in 2026 audits.

TL;DR: Shopify CDN TTFB is rarely the problem. The 4 Liquid patterns that push TTFB past 1.8s: nested for loops, all_products iteration, metafield N+1 reads, deep section recursion. Each is auditable via ?profile=1 and fixable in a single PR. Median Shopify Plus p75 TTFB is 400-800ms; stores past 1.5s almost always have a Liquid issue.

Why TTFB matters separately from LCP

  • TTFB (Time to First Byte) is the green-band metric Google uses as a Core Web Vital input. Under 800ms is the fast threshold.
  • TTFB sits inside the LCP measurement; a 1.6s TTFB caps LCP at 1.6s minimum. Every millisecond of server-render time is a millisecond LCP cannot recover.
  • TTFB is the only metric the customer experiences before any pixels paint. 2.4s of blank screen is the felt cost on the storefront.

The Shopify CDN handles the network and cache layers reliably. What it cannot fix is a 1,400ms Liquid render. That part is on the theme.

Killer 1: nested for loops over large collections

The audit case:

{%- comment -%} BAD: nested loop with metafield reads {%- endcomment -%}
{%- for product in collections.all.products -%}
  {%- for variant in product.variants -%}
    {%- if variant.metafields.custom.featured == 'true' -%}
      ...
    {%- endif -%}
  {%- endfor -%}
{%- endfor -%}

On a store with 250 products averaging 5 variants each, the outer loop runs 250 times. The inner loop runs 5 times per outer iteration. The metafield read inside fires 1,250 times per page load. Shopify’s Liquid runtime caps collections.all.products at 250 items by default, but that cap is the upper bound, not a fix; even 250 outer iterations is too many for synchronous render.

The fix is moving the filter to a metafield-based collection or a smart collection that pre-computes membership:

{%- assign featured = collections.featured-variants.products -%}
{%- for product in featured limit: 12 -%}
  ...
{%- endfor -%}

Smart collections evaluate membership at write time in Shopify Admin, not on every page render. Render time drops from 800-1,400ms to 40-90ms on the audit case.

Killer 2: all_products iteration

collections.all.products is the catalog dump. Iterating it on a page that does not need the full catalog is the most common TTFB killer. Common offenders:

  • “Featured products” sections that pull all_products and filter by tag in Liquid (should use a tagged smart collection)
  • Search index pre-builds that iterate all_products to emit a JSON blob (should use the Search & Discovery API)
  • Variant-level option counters that loop all_products to count colour options (should pre-aggregate via metafield)

The smell test: any for product in collections.all.products on a non-search template is suspect. Audit it against the actual data need. If you need 12 products, fetch 12, not 250.

Killer 3: metafield N+1 reads

Liquid metafield reads look free in the source but cost real time at render. Shopify’s Liquid runtime fetches metafields per-object on first access. Reading product.metafields.custom.spec_sheet inside a for product in collection.products loop fires one metafield query per product, per page render.

The fix is the metafields_data preload pattern via Liquid’s batch metafield access, or restructuring the data into a single metaobject that pre-aggregates the values:

{%- comment -%} BEFORE: N+1 metafield reads {%- endcomment -%}
{%- for product in collection.products -%}
  <span>{{ product.metafields.custom.warranty }}</span>
{%- endfor -%}

{%- comment -%} AFTER: read once, render from cache {%- endcomment -%}
{%- assign warranty_lookup = shop.metafields.custom.warranty_by_product_handle.value -%}
{%- for product in collection.products -%}
  <span>{{ warranty_lookup[product.handle] }}</span>
{%- endfor -%}

The metaobject approach pre-aggregates the values in admin. The lookup at render time is a single object dereference per product, not a metafield query.

For the broader Shopify metafield patterns, see my Shopify metaobjects vs metafields post.

Killer 4: deep section recursion in JSON templates

OS 2.0 JSON templates let you nest sections inside sections via app blocks and block schema. Each nesting level costs render time. A homepage with 20 sections, each with 5-10 blocks, each invoking 2-3 snippets via {% render %}, can hit 600-1,200ms of pure Liquid render time even before any data fetching.

The audit move:

  1. Hit ?profile=1 on the storefront URL while logged in as store owner. Shopify renders a profiler at page bottom showing every section’s render time.
  2. Sort by render time descending.
  3. Anything above 50ms per section deserves audit. Above 200ms is the bottleneck.

The common culprits in audit:

  • A “trust badges” section rendering 8 SVG icons via {% render %} with parameters (each render is parsed + executed separately; should be inline)
  • A “featured products” section rendering full product cards with 4-5 metafield reads each (should use a lightweight card snippet)
  • A “blog feed” section iterating articles with full HTML rendering (should use a list with linked headings only)

Refactor the heaviest section first. One section at 400ms is usually the biggest gain you can ship in one PR.

How to audit your TTFB in 5 minutes

Three checks, in this order.

curl -w:

curl -w '%{time_starttransfer}' -o /dev/null -s https://yourstore.com/products/best-seller

Returns the TTFB in seconds. Under 0.8 is green, 0.8-1.8 needs improvement, above 1.8 is the diagnosis zone.

?profile=1: log in as the store owner, append ?profile=1 to any URL. Shopify renders the Liquid profiler at the bottom of the page. Sort sections by render time. The top 3 sections by render time are your refactor targets.

CrUX field data: PageSpeed Insights shows p75 TTFB in the Field Data section, rolling 28-day window. Day 1 looks pre-fix; day 28 reflects the new value.

For the broader Core Web Vitals optimisation across LCP plus INP plus CLS plus TTFB, see my Shopify Core Web Vitals 2026 guide and the CrUX Grader tool.

What this does not fix

Three TTFB patterns the 4 fixes above do not solve:

  • Signed-in customer pages. These bypass the edge cache. TTFB depends entirely on Liquid render time, with no CDN buffer. Optimising the Liquid is the only lever.
  • B2B catalogs with customer-tag pricing. Each customer sees a personalised price, which forces a Liquid-side re-render per customer. Cart and PDP TTFB will sit higher than signed-out equivalents. Acceptable trade-off for the B2B feature set.
  • Apps that inject server-side code via Liquid tag injection. Some review apps and bundle apps inject Liquid that runs server-side on every page. If you cannot defer or remove the app’s Liquid injection, the TTFB cost is fixed. See my third-party script defer playbook for the JS side of the same problem.

The takeaway

  • Shopify CDN is rarely the TTFB problem. Diagnose Liquid first. Under 800ms is the green target; 1.5s+ signals a Liquid render-time issue.
  • Nested loops over collections.all.products are the most common killer. Move filtering to smart collections; render time drops 10-20x in the audit cases I have shipped.
  • Metafield N+1 reads compound silently. Pre-aggregate via metaobjects or shop-level metafields; lookup at render time is a single dereference.
  • Section recursion in JSON templates accumulates fast. ?profile=1 reveals the top render-time sections. One refactor on the heaviest section usually moves p75 TTFB by 300-600ms.
  • Measure with curl + ?profile=1 first, CrUX over 28 days for field proof. Lab moves immediately; field is a rolling window.

Frequently Asked Questions

What is a good TTFB for a Shopify storefront?

Under 800ms is the Google Core Web Vitals fast threshold; 800ms-1.8s is needs-improvement; above 1.8s is poor. Shopify Plus storefronts on Shopify's edge CDN typically land at 200-600ms p75 TTFB when the theme is well-tuned. Stores past 1.5s almost always have a Liquid render-time issue (deep loops, metafield N+1, or all_products iteration), not a Shopify CDN issue. The 4 patterns in this post account for most TTFB problems I diagnose.

How do I measure Shopify TTFB?

Three ways. First, browser DevTools Network tab: click the HTML document request, see the 'Waiting for server response' time. Second, `curl -w '%{time_starttransfer}' -o /dev/null -s https://yourstore.com/products/...`. Third, CrUX field data via PageSpeed Insights (the Field Data section shows p75 TTFB at 28-day rolling). The browser and curl values are lab measurements; CrUX is real-user. Use CrUX for the 28-day proof, curl for the day-1 sanity check on theme deploys.

Does Shopify's CDN cause TTFB problems?

Almost never on Shopify Plus, rarely on Advanced and below. Shopify's CDN runs at the edge with response time SLAs under 200ms for cached responses. Cache misses (signed-in customers, draft orders, certain Sections API responses) can hit 400-800ms, which is still inside the green band. TTFB problems above 1.5s on a Shopify storefront are almost always Liquid render-time, not CDN. Diagnose the Liquid first; the CDN is rarely the bottleneck.

How do I find the slow Liquid in my theme?

Add `?profile=1` to any storefront URL while logged in as the store owner. Shopify renders a Liquid profiler at the bottom of the page showing every section, snippet, and tag with its render time. Sort by render time descending. Anything above 50ms per section deserves audit; anything above 200ms per section is the bottleneck. The profiler reveals nested loops, metafield N+1, and deep section recursion that you cannot see in the Liquid source by eye.

Does using image_url with width parameters slow down Liquid?

Marginally, but not enough to matter. Each `image_url` call with a width parameter takes ~1-3ms of Liquid render time because Shopify generates the derivative URL hash on the server. A 24-card collection page with one hero image per card adds ~50-70ms. That cost is dwarfed by the network savings from serving a 400px image to a 400px viewport instead of a 3000px source. The TTFB hit is real but immaterial; the LCP gain is large.

Can I cache Liquid output to reduce TTFB?

Shopify auto-caches storefront HTML at the CDN edge for signed-out customers, which is the dominant traffic pattern. You cannot manually control the cache lifetime. For signed-in customers and cart-personalised pages (which bypass the cache), TTFB is governed entirely by Liquid render time, which is why optimising the Liquid matters more than fighting the cache. The Section Rendering API endpoint provides per-section cacheable responses for cart drawer and PDP variant updates.

Book Strategy Call