I audited 14 Shopify themes last quarter for speed. 11 of them blamed apps. None had touched Liquid loop count, capture-in-loop allocations, or image output.
After optimizing 100+ Shopify stores over 12 years: the code-level patterns in your theme files account for 40-60% of total render time. Apps matter. Images matter. The template layer is where the compounding problems live.
Here are the 5 Liquid patterns that move the needle.
1. Drop capture from loops
assign stores a value. capture renders a full block and stores it as a string. Using capture inside a loop means a new string allocation on every iteration.
Slow — 48 allocations on a 48-product collection:
{% for product in collection.products %}
{% capture product_card %}
<div class="product-card">
<h3>{{ product.title }}</h3>
<p>{{ product.price | money }}</p>
</div>
{% endcapture %}
{{ product_card }}
{% endfor %}
Fast — direct output, zero allocations:
{% for product in collection.products %}
<div class="product-card">
<h3>{{ product.title }}</h3>
<p>{{ product.price | money }}</p>
</div>
{% endfor %}
Use capture only when you need a reusable HTML block built once and output in a different location.
2. Cap nested loops with limit and {% break %}
Nested loops are the single biggest source of Liquid render time problems.
Slow — 2,500 iterations on a featured collections section:
{% for collection in collections %}
{% for product in collection.products %}
{% for image in product.images %}
<img src="{{ image | image_url: width: 300 }}" alt="{{ image.alt }}">
{% endfor %}
{% endfor %}
{% endfor %}
Fast — 32 iterations, using featured_image and limit:
{% for collection in collections limit: 4 %}
{% for product in collection.products limit: 8 %}
{% if product.featured_image %}
<img src="{{ product.featured_image | image_url: width: 300 }}"
alt="{{ product.featured_image.alt | default: product.title }}"
width="300" height="300" loading="lazy">
{% endif %}
{% endfor %}
{% endfor %}
Use {% break %} to stop early once you have the N items you need. Use {% continue %} to skip non-matching items without a nested if.
Real result: Factory Direct Blinds went from 4,800 collection iterations to 216. LCP dropped from 22s to 2.7s.
3. Output images with image_tag (srcset + dimensions)
This single change can improve LCP by 500ms+ on collection pages.
Slow — no srcset, no dimensions, no lazy loading:
<img src="{{ product.featured_image | image_url: width: 800 }}">
Fast:
{{ product.featured_image | image_url: width: 800 | image_tag:
srcset: "200,400,600,800",
sizes: "(max-width: 768px) 100vw, 400px",
loading: "lazy",
decoding: "async",
alt: product.featured_image.alt | default: product.title,
width: 800,
height: 800
}}
Critical: Your hero and first visible product image should use loading: "eager", not lazy. Lazy-loading your LCP element is one of the most common speed mistakes I see on audits.
To handle this in a grid, conditionally eager-load the first 4:
{% for product in collection.products limit: 24 %}
{% assign img_loading = forloop.index <= 4 | iif: "eager", "lazy" %}
{{ product.featured_image | image_url: width: 600 | image_tag:
loading: img_loading,
width: 600,
height: 600
}}
{% endfor %}
4. Preload hero image and critical font in theme.liquid
{% if template == 'index' %}
{% assign hero_image = section.settings.hero_image %}
{% if hero_image %}
<link rel="preload" as="image"
href="{{ hero_image | image_url: width: 1200 }}"
imagesrcset="{{ hero_image | image_url: width: 600 }} 600w, {{ hero_image | image_url: width: 1200 }} 1200w"
imagesizes="100vw">
{% endif %}
{% endif %}
<link rel="preload" as="font" type="font/woff2"
href="{{ 'your-heading-font.woff2' | asset_url }}" crossorigin>
Wrap third-party preconnects in conditionals so they only fire when the feature is enabled:
{% if settings.enable_reviews %}
<link rel="dns-prefetch" href="https://api.judge.me">
{% endif %}
5. Push dynamic content onto the Section Rendering API
Instead of a full page reload to update one section, fetch just that section's HTML.
Slow — full page reload on filter click:
window.location.href = newUrl;
Fast — Section Rendering API:
async function updateCollection(url) {
const sectionId = 'collection-grid';
const response = await fetch(`${url}?sections=${sectionId}`);
const data = await response.json();
document.getElementById(sectionId).innerHTML = data[sectionId];
}
Response size drops from 100KB+ to 5-15KB. Perceived load time drops from 2-3 seconds to 200-400ms.
Good candidates: collection filtering, cart drawer, product recommendations, quick-view modals.
Before and after numbers
| Store | Metric | Before | After |
|---|---|---|---|
| WD Electronics | Mobile LCP | 9.3s | 3.1s |
| WD Electronics | Lighthouse | 41 | 72 |
| WD Electronics | DOM Elements | 4,200+ | 1,100 |
| Factory Direct Blinds | Mobile PageSpeed | 38 | 81 |
| Factory Direct Blinds | LCP | 22.0s | 2.7s |
| Factory Direct Blinds | Liquid Render | 840ms | 65ms |
Both stores saw measurable conversion improvements within 30 days of deploying the speed fixes.
How to verify in 5 minutes
- Install the Shopify Theme Inspector Chrome extension. Open DevTools, go to the Shopify tab, reload your slowest collection page. Total Liquid render time should be under 100ms. Over 200ms means a section is bleeding render budget.
- Open PageSpeed Insights on Mobile. Read Field Data first — that is real Chrome users, the metric Google ranks on.
- Check Search Console > Experience > Core Web Vitals for which URL groups are flagged Poor.
The full post (with more code and the FAQ section) is at kaspianfuad.com.
If you want a professional audit of your theme's Liquid performance, I run a Shopify speed-focused CRO audit that covers every pattern above plus the JS and third-party script layer.
Top comments (0)