---
title: Defer dialog content loading until user interaction
description: >-
  Guard expensive Liquid operations inside dialogs, drawers, and expandable
  components with conditional parameters to prevent loading content on every
  page request when the component isn't visible.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-modal-content-loading
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-modal-content-loading.md
api_name: liquid
---

# Defer dialog content loading until user interaction

Guard expensive Liquid operations inside dialogs, drawers, and expandable components with conditional parameters to prevent loading content on every page request when the component isn't visible.

***

## Why

Dialogs and drawers that load content on every page request waste server resources even when users never open them. Common expensive operations include collection queries (`collections.all.products`), variant iteration (`product.variants`), and metafield access. All of these execute during initial page render if not guarded.

The predictive search dialog pattern from Horizon v2.1.4 demonstrates the impact: removing an unguarded `settings.empty_state_collection.products | default: collections.all.products` assignment saved 400 ms on every page load by preventing evaluation of `product.available`, `product.price_max`, and other properties that must assess all variants.

For stores with high-variant products (1,000 or more variants per product), accessing `collections.all` without a `limit` can add 1 to 3 or more seconds to TTFB because the server loads full product data for potentially hundreds of products.

***

## How

### Add conditional loading parameters

Create a parameter that controls whether the expensive content loads:

## snippets/search-modal-content.liquid

```liquid
{% doc %}
  @param {boolean} [load_content] - Whether to load the modal content.
{% enddoc %}


{% if load_content %}
  {% liquid
    assign collection = settings.featured_collection | default: collections.all
    assign title = collection.title
  %}


  {%- for product in collection.products limit: 4 -%}
    {{ product.title }}
  {%- endfor -%}
{% endif %}
```

### Load content on user interaction

On initial render, include the dialog shell with `load_content: false`. When the user opens the dialog, fetch a dedicated section that renders the snippet with `load_content: true` through the [Section Rendering API](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-rendering-api):

## sections/search-modal-loaded.liquid

```liquid
{%- comment -%}
  Dedicated section used only by the Section Rendering API to return the
  search dialog with its expensive content loaded. It repeats the `#search-modal`
  wrapper because the click handler looks that element up in the response.
{%- endcomment -%}
<div id="search-modal">
  {% render 'search-modal-content', load_content: true %}
</div>
```

## sections/header.liquid

```liquid
<button id="search-toggle">Search</button>


<div id="search-modal" hidden>
  {% render 'search-modal-content', load_content: false %}
</div>


<script>
  const searchToggle = document.querySelector('#search-toggle');
  let searchModal = document.querySelector('#search-modal');
  let contentLoaded = false;


  searchToggle.addEventListener('click', async () => {
    if (!contentLoaded) {
      // Fetch the dedicated section that renders the modal with load_content: true.
      const response = await fetch('?sections=search-modal-loaded');
      const data = await response.json();
      const parser = new DOMParser();
      const doc = parser.parseFromString(data['search-modal-loaded'], 'text/html');
      const loadedContent = doc.querySelector('#search-modal');


      searchModal.replaceWith(loadedContent);
      // Re-query the DOM — `searchModal` now references the detached old node.
      searchModal = document.querySelector('#search-modal');
      contentLoaded = true;
    }


    searchModal.hidden = false;
  });
</script>
```

### Fetch only the items you render

Even with conditional loading, keep the query small. For `collection.products`, a `for` loop's `limit` already reduces the fetch, so a fixed-size list doesn't need a `{% paginate %}` wrapper, and adding one couples the section to the `page` URL parameter. Other arrays, such as `blog.articles`, need `{% paginate %}` to limit the fetch. See [Limit how many items a Liquid array fetches](https://shopify.dev/docs/storefronts/themes/best-practices/performance/limit-product-queries-with-pagination) for which arrays behave which way.

***

## Examples

### Predictive search dialog (Horizon v2.​1.​4 pattern)

Before optimization:

```liquid
{%- comment -%} Bad: loads on every page request {%- endcomment -%}
<div class="predictive-search-empty-state">
  {% liquid
    assign products = settings.empty_state_collection.products | default: collections.all.products
    assign default_title = 'content.search_results_resource_products' | t
    assign title = settings.empty_state_collection.title | default: default_title
  %}


  {% if products.size > 0 %}
    {% render 'predictive-search-products-list',
      title: title,
      products: products,
      limit: 4
    %}
  {% endif %}
</div>
```

Problems:

* Loads `collections.all.products` on every page.
* No `limit` on the loop, so it fetches the full default page size of 50 products.
* Evaluates `product.available` and `product.price_max` for all variants.
* Adds 400 ms or more to TTFB on stores with high-variant products.

After optimization:

```liquid
{%- comment -%} Good: loads only when the modal opens {%- endcomment -%}
<div class="predictive-search-empty-state">
  {% if load_empty_state %}
    {% liquid
      assign collection = settings.empty_state_collection | default: collections.all
      assign default_title = 'content.search_results_resource_products' | t
      assign title = settings.empty_state_collection.title | default: default_title
    %}


    {% assign products = collection.products %}


    {% if products.size > 0 %}
      {% render 'predictive-search-products-list',
        title: title,
        products: products,
        limit: 4
      %}
    {% endif %}
  {% endif %}
</div>
```

Benefits:

* No collection query on the initial page load.
* The `limit: 4` passed to the products list caps the loop, which reduces the fetch to 4 products instead of the default 50.
* 400 ms faster TTFB on every page.
* Content loads on the first dialog open using JavaScript.

### Cart drawer with recommendations

The `recommendations` object is only populated when the section is rendered through the [Product Recommendations API](https://shopify.dev/docs/api/ajax/reference/product-recommendations) endpoint (`/recommendations/products?section_id=...&product_id=...`), not through a generic Section Rendering API request. Render the drawer shell on the initial page load without recommendations, then fetch the recommendations section from the correct endpoint when the drawer opens.

## sections/cart-drawer-recommendations.liquid

```liquid
{%- comment -%}
  Dedicated section targeted by /recommendations/products so the
  `recommendations` object is populated.
{%- endcomment -%}
<div class="cart-recommendations">
  <h3>{{ 'cart.recommendations' | t }}</h3>


  {%- if recommendations.performed? and recommendations.products_count > 0 -%}
    {%- for product in recommendations.products limit: 4 -%}
      {% render 'product-card', product: product %}
    {%- endfor -%}
  {%- endif -%}
</div>
```

Usage:

```liquid
{%- comment -%}
  Initial render: drawer shell only, no recommendations. `data-product-id` seeds
  the recommendations request. It's empty when the cart is empty, and the click
  handler skips the fetch in that case.
{%- endcomment -%}
<div id="cart-drawer" data-product-id="{{ cart.items.first.product_id }}" hidden>
  <div id="cart-recommendations-slot"></div>
</div>


<script>
  // Load recommendations when the drawer opens.
  document.querySelector('#cart-toggle').addEventListener('click', async () => {
    const drawer = document.querySelector('#cart-drawer');
    const slot = drawer.querySelector('#cart-recommendations-slot');
    const productId = drawer.dataset.productId;


    if (!drawer.dataset.loaded && productId) {
      const response = await fetch(
        `/recommendations/products?section_id=cart-drawer-recommendations&product_id=${productId}&limit=4`
      );
      const html = await response.text();
      const parser = new DOMParser();
      const doc = parser.parseFromString(html, 'text/html');
      const rendered = doc.querySelector('.cart-recommendations');


      if (rendered) slot.replaceWith(rendered);
      drawer.dataset.loaded = 'true';
    }


    drawer.hidden = false;
  });
</script>
```

### Accordion and tabs with expensive content

## snippets/product-details-tab.liquid

```liquid
{% doc %}
  @param {boolean} [load_details] - Whether to load the tab content.
{% enddoc %}


<div class="product-tab" data-tab="details">
  <button class="tab-trigger">Product Details</button>


  <div class="tab-content" hidden>
    {% if load_details %}
      {% liquid
        assign related_collection = product.metafields.custom.related_collection.value
      %}


      {% if related_collection %}
        {%- for related_product in related_collection.products limit: 6 -%}
          {{ related_product.title }}
        {%- endfor -%}
      {% endif %}
    {% endif %}
  </div>
</div>
```

### Size guide dialog with variant data

## snippets/size-guide-modal.liquid

```liquid
{% doc %}
  @param {boolean} [load_size_guide] - Whether to load size guide data.
{% enddoc %}


<dialog id="size-guide-modal">
  {% if load_size_guide %}
    <table class="size-guide">
      <thead>
        <tr>
          <th>Size</th>
          <th>Measurements</th>
          <th>Available</th>
        </tr>
      </thead>
      <tbody>
        {%- comment -%} Access only the selected variant, not all variants {%- endcomment -%}
        {% assign variant = product.selected_or_first_available_variant %}
        {% if variant.metafields.custom.size_guide %}
          <tr>
            <td>{{ variant.option1 }}</td>
            <td>{{ variant.metafields.custom.size_guide.value }}</td>
            <td>{{ variant.available }}</td>
          </tr>
        {% endif %}
      </tbody>
    </table>
  {% endif %}
</dialog>
```

***

## Testing

* **Measure TTFB**: Compare before and after with dialog content guarded. Should improve by 100 to 400 ms or more depending on query complexity.
* **Theme Inspector**: Use the sandwich view to verify that expensive operations, such as collection queries and variant iteration, no longer execute on the initial page load.
* **Network tab**: Verify that dialog content loads through the Section Rendering API when the user opens the dialog.
* **Test with realistic data**: Use products with 100 or more variants and collections with 50 or more products to see the real impact.

***

## References

* [`paginate`](https://shopify.dev/docs/api/liquid/tags/paginate) tag
* [`if`](https://shopify.dev/docs/api/liquid/tags/if) tag
* [`collections.all`](https://shopify.dev/docs/api/liquid/objects/collections)
* [`product.variants`](https://shopify.dev/docs/api/liquid/objects/product#product-variants)
* [`recommendations.products`](https://shopify.dev/docs/api/liquid/objects/recommendations#recommendations-products)
* [Use the Section Rendering API for dynamic updates](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-rendering-api)
* [Limit pagination depth](https://shopify.dev/docs/storefronts/themes/best-practices/performance/limit-pagination-depth)
* [Avoid over-fetching product variants](https://shopify.dev/docs/storefronts/themes/best-practices/performance/avoid-variant-overfetching)
* [Avoid deeply nested Liquid loops](https://shopify.dev/docs/storefronts/themes/best-practices/performance/avoid-deeply-nested-liquid-loops)

***
