A backend dev pulled up his client’s collection template in October 2025 and said, “the server is taking 380 milliseconds before it sends a single byte.” We added limit: 24 and a single {% break %} after the featured-product count hit 8. Server-side Liquid render dropped to 64ms. Same template, same data, eight extra characters.
TL;DR: Liquid {% break %} exits the innermost {% for %} immediately. {% continue %} skips the current iteration. Both ship on every Shopify theme since Liquid was open-sourced. Five patterns below cover the common cases: short-circuit on first match, filter-in-loop, paired-with-limit, nested-loop guard, and tablerow early exit.
Why this matters for your store:
- A 1,200-product collection that fully scans on every page load adds 200-400ms to Time to First Byte. Mobile users on slow 4G feel that as a half-second of blank screen.
- Liquid runs server-side on every cache miss. A new buyer hitting an uncached collection page pays the full render cost.
- Theme Check does not flag missing
breakorcontinuepatterns. Performance regressions slip through PR review because the code “works.”
How break and continue execute in Liquid
{% break %} halts the innermost {% for %} block. The remaining items in the iteration list are skipped entirely. The parser jumps to the matching {% endfor %} and continues with whatever sits below it.
{# sections/featured-row.liquid #}
{% for product in collection.products %}
{% if product.tags contains 'featured' %}
{% render 'product-card', product: product %}
{% assign featured_count = featured_count | plus: 1 %}
{% if featured_count >= 8 %}{% break %}{% endif %}
{% endif %}
{% endfor %}
This pattern scans the collection only until it finds 8 featured products. On a 1,200-product collection where 12 carry the featured tag, Liquid evaluates roughly the first 800 products before the count hits 8, then exits. Worst case is still O(n), but the early exit cuts the average case in half.
{% continue %} skips the current iteration and moves to the next item in the list. The body block below continue is not evaluated for that iteration.
{% for product in collection.products limit: 24 %}
{% if product.tags contains 'archive' %}{% continue %}{% endif %}
{% render 'product-card', product: product %}
{% endfor %}
This pattern filters archive products out of the rendered grid without dropping below the merchant’s expected count. The limit: 24 caps the iteration list itself, so the loop runs no more than 24 times regardless of how many archive products it skips.
For broader Liquid loop performance work including the nested-loop blowup pattern, see my Shopify Liquid loop optimization guide and the Liquid development guide for the pillar fundamentals.
Pattern 1: short-circuit on first match
The simplest break case. You need to find one value, stop when you have it.
{# snippets/find-featured-image.liquid #}
{% assign featured_image = blank %}
{% for image in product.images %}
{% if image.alt contains 'hero' %}
{% assign featured_image = image %}
{% break %}
{% endif %}
{% endfor %}
{% if featured_image != blank %}
{{ featured_image | image_url: width: 1200 | image_tag: loading: 'eager' }}
{% endif %}
Without the break, Liquid walks every image even after finding the hero. On a PDP with 14 images and 5 different gallery sections rendering this pattern, the no-break version added 70-80 image accesses per request. The break version adds 1 to 14 depending on where the hero sits.
Pattern 2: filter-in-loop with continue
Filter mid-loop without nesting an if around the body. Reads cleaner, handles 3+ skip conditions without a deep indent.
{# sections/collection-grid.liquid #}
{% for product in collection.products limit: 60 %}
{% if product.tags contains 'archive' %}{% continue %}{% endif %}
{% if product.available == false and section.settings.hide_oos %}{% continue %}{% endif %}
{% if product.metafields.custom.private == true %}{% continue %}{% endif %}
{% render 'product-card', product: product %}
{% endfor %}
Three guards, three continue exits, one rendered body. The alternative is a deeply-nested if that wraps the entire render call. Continue keeps the rendered body at indent level 1, which makes the body itself easy to read.
Pattern 3: limit plus break for capped scans
{% for %} with limit: caps the iteration list before the loop starts. Break inside the loop caps the visible count after some filtering. Together they bound both ends.
{# sections/related-products.liquid #}
{% assign related_count = 0 %}
{% for product in collection.products limit: 48 %}
{% if product.handle == current_product.handle %}{% continue %}{% endif %}
{% if related_count >= 4 %}{% break %}{% endif %}
{% render 'product-card', product: product %}
{% assign related_count = related_count | plus: 1 %}
{% endfor %}
This pattern shows 4 related products from a collection of up to 48, skipping the current product. The outer cap (48) bounds the worst case server-side render. The inner cap (4) bounds the rendered card count. The continue handles the duplicate-self case.
On a Mobelglede.no PDP in March 2026, this pattern replaced a custom “related products” Klaviyo block. Page weight dropped 86KB. Server render stayed flat.
Pattern 4: nested loop with parent break
{% break %} only exits the innermost loop. To exit a nested structure, set a flag the outer loop checks.
{# sections/variant-finder.liquid #}
{% assign found_variant = blank %}
{% for product in collection.products %}
{% if found_variant != blank %}{% break %}{% endif %}
{% for variant in product.variants %}
{% if variant.sku == target_sku %}
{% assign found_variant = variant %}
{% break %}
{% endif %}
{% endfor %}
{% endfor %}
The inner break exits the variants loop the moment we find the SKU. The outer loop checks the found_variant flag at the top of each iteration and breaks immediately if it is set. Without the parent check, the outer loop would keep scanning products even after the inner break fired.
This is the pattern most often miswritten as “labelled break” by devs coming from Java or Ruby. Liquid has no labelled break. The flag is the idiom.
Pattern 5: tablerow early exit
{% tablerow %} is the table-friendly cousin of {% for %}. It supports break the same way.
{# templates/page.contact.liquid #}
<table>
{% tablerow team_member in shop.metafields.about.team cols: 3 %}
{% if team_member.archived %}{% continue %}{% endif %}
<td>
<img src="{{ team_member.photo | image_url: width: 200 }}" alt="{{ team_member.name }}">
<h3>{{ team_member.name }}</h3>
</td>
{% endtablerow %}
</table>
continue inside a tablerow skips one cell. The grid layout absorbs the skip cleanly because cols: 3 wraps every 3 cells regardless of which iterations rendered. break exits the entire tablerow.
How to verify your loops break correctly
Three checks, five minutes.
-
Add
{{ forloop.index }} of {{ forloop.length }}temporarily. Render it before any render tag inside the loop, preview the page, and confirm the index stops where you expect. If the loop runs toforloop.lengtheven after break should have fired, your break condition is wrong. -
Profile with the Shopify Theme Inspector. Install the Shopify Theme Inspector for Chrome and load the page. The render-time graph shows which sections eat the budget. A loop that should break early but doesn’t will dominate the chart.
-
Curl the server timing header.
curl -sI https://your-store.com/collections/all | grep -i 'server-timing'. Shopify exposes aprocessingdirective in the server-timing header. Compare before and after your break optimization. Improvements of 100ms+ on a 1,200-product collection are typical.
The takeaway:
- Use
{% break %}to short-circuit a loop once you have what you need. - Use
{% continue %}to filter mid-loop without nestingifaround the body. - Always pair
{% for %}withlimit:to bound the worst-case scan length. - For nested loops, set a flag in the inner loop and break the outer loop on the next iteration.
- Profile with the Theme Inspector, not your gut. A 380ms render that drops to 64ms is the size of win you can verify.