Skip to main content

Move repeated assign and filter calls outside loops

Move calculations and filter operations outside of loops. Assign the result to a variable before the loop and reuse it, rather than recalculating on each iteration.


When you perform an operation inside a for loop that doesn't depend on the loop variable, you're calculating the same thing multiple times. The result doesn't change between iterations, but Liquid executes the operation again each time. Moving these operations before the loop reduces server-side Liquid execution time.

The impact scales with both the number of iterations and the cost of the operation. A sort filter inside a 50-product loop runs 50 times, producing the same sorted array each time. An asset_url filter that resolves the same asset path on every iteration wastes time resolving a value that could be resolved once. These add directly to TTFB, which delays FCP and LCP.


Identify any operation inside a loop whose result doesn't depend on the current iteration's variable. Move it above the loop and assign it to a variable.

Common operations to hoist:

  • assign with static values: Any assign that doesn't reference the loop variable.
  • capture blocks: Blocks that produce the same output on every iteration.
  • image_url: When the image doesn't change per iteration, for example, a placeholder or brand logo.
  • asset_url: Asset paths are the same on every iteration. Resolve them once.
  • sort and where filters: Sorting or filtering a collection produces the same result each time.
  • money filter on static values: Formatting a fixed price or threshold.
  • size: Counting the size of an array that doesn't change inside the loop.

Anchor to Basic: sort operation inside a loopBasic: sort operation inside a loop

{%- comment -%} Less performant: sorts the entire collection on every iteration {%- endcomment -%}
{%- for product in collection.products -%}
{% assign sorted = collection.products | sort: "price" %}
{{ product.title }}
{%- endfor -%}

{%- comment -%} More performant: sort once before the loop {%- endcomment -%}
{% assign sorted = collection.products | sort: "price" %}

{%- for product in sorted -%}
{{ product.title }}
{%- endfor -%}

Anchor to Real-world product card snippet with multiple hoistable operationsReal-world product card snippet with multiple hoistable operations

This example shows a product card loop where several operations are repeated on every iteration but produce the same result:

Less performant:

{%- for product in collection.products -%}
{% assign placeholder = 'product-1' | placeholder_svg_tag %}
{% assign badge_icon = 'icon-badge.svg' | asset_url %}
{% assign free_shipping_threshold = section.settings.free_shipping_threshold | money %}
{% assign total_products = collection.products | size %}

<div class="product-card">
{% if product.featured_image != blank %}
{{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
{% else %}
{{ placeholder }}
{% endif %}

<h3>{{ product.title }}</h3>
<p>{{ product.price | money }}</p>

{% if product.price >= section.settings.free_shipping_threshold %}
<img src="{{ badge_icon }}" alt="" width="16" height="16">
<span>Free shipping on orders over {{ free_shipping_threshold }}</span>
{% endif %}

<p>{{ forloop.index }} of {{ total_products }}</p>
</div>
{%- endfor -%}

More performant:

{%- comment -%}
These values are the same on every iteration.
Calculate them once before the loop.
{%- endcomment -%}
{% assign placeholder = 'product-1' | placeholder_svg_tag %}
{% assign badge_icon = 'icon-badge.svg' | asset_url %}
{% assign free_shipping_threshold_formatted = section.settings.free_shipping_threshold | money %}
{% assign total_products = collection.products | size %}

{%- for product in collection.products -%}
<div class="product-card">
{% if product.featured_image != blank %}
{{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
{% else %}
{{ placeholder }}
{% endif %}

<h3>{{ product.title }}</h3>
<p>{{ product.price | money }}</p>

{% if product.price >= section.settings.free_shipping_threshold %}
<img src="{{ badge_icon }}" alt="" width="16" height="16">
<span>Free shipping on orders over {{ free_shipping_threshold_formatted }}</span>
{% endif %}

<p>{{ forloop.index }} of {{ total_products }}</p>
</div>
{%- endfor -%}

In this example, four operations moved outside the loop. With 50 products, that's 200 fewer filter evaluations (4 operations × 50 iterations).

Anchor to Hoisting capture blocksHoisting capture blocks

{%- comment -%} Less performant: capture rebuilds the same string each iteration {%- endcomment -%}
{%- for product in collection.products -%}
{% capture empty_state %}
<div class="empty-state">
<p>{{ 'products.no_image' | t }}</p>
</div>
{% endcapture %}

{% if product.featured_image != blank %}
{{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
{% else %}
{{ empty_state }}
{% endif %}
{%- endfor -%}

{%- comment -%} More performant: capture once before the loop {%- endcomment -%}
{% capture empty_state %}
<div class="empty-state">
<p>{{ 'products.no_image' | t }}</p>
</div>
{% endcapture %}

{%- for product in collection.products -%}
{% if product.featured_image != blank %}
{{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
{% else %}
{{ empty_state }}
{% endif %}
{%- endfor -%}

  • Use the Theme Inspector Chrome extension to identify repeated operations inside loops. The flame graph shows per-snippet timing, and the sandwich view shows cumulative time spent on repeated operations.
  • Compare TTFB in the Chrome DevTools Network panel before and after hoisting operations. Filter to the document request and check the Waiting for server response time.
  • Focus on collection pages and other pages where loops iterate many times. The impact scales with the iteration count.


Was this page helpful?