---
title: Defer child product loading in combined listings
description: >-
  Use `product.options_with_values` and `product_option_value.product_url` to
  render combined listing options on initial load, and fetch child product
  content only when a buyer selects an option value.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-combined-listing-products
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-combined-listing-products.md
api_name: liquid
---

# Defer child product loading in combined listings

Use [`product.options_with_values`](https://shopify.dev/docs/api/liquid/objects/product#product-options_with_values) to render combined listing options without loading child product data, and load a child product only when a buyer selects an option value, using the [Section Rendering API](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-rendering-api).

***

## Why

A [combined listing](https://help.shopify.com/manual/products/combined-listings) is a parent product that links several child products together so that they're merchandised as one item. Each child product is a separate product with its own variants, images, and URL, so rendering all of them up front multiplies backend queries. A parent with 20 to 60 children can add seconds to [TTFB](https://web.dev/ttfb/), and including descriptions and full image sets for each child makes it worse.

***

## Decide whether this applies to you

Combined listings are available on Shopify Plus plans only, and they're supported on the online store only. If your store isn't on Shopify Plus, then you don't need this pattern.

***

## How

Render option values with [`product.options_with_values`](https://shopify.dev/docs/api/liquid/objects/product#product-options_with_values) and the [`product_option_value`](https://shopify.dev/docs/api/liquid/objects/product_option_value) objects returned by [`product_option.values`](https://shopify.dev/docs/api/liquid/objects/product_option#product_option-values). These give you availability and selection state without loading child products.

To switch between sibling products, use [`product_option_value.product_url`](https://shopify.dev/docs/api/liquid/objects/product_option_value#product_option_value-product_url). It returns the URL of the product that's associated with an option value, and it's the only Liquid mechanism for combined listing navigation.

**Info:**

`product_option_value.product_url` returns `nil` unless the current product is a combined listing parent, and it also returns `nil` for option values that the child product owns. On a parent page, the parent's own options (such as color) return sibling product URLs, and options that come from a child (such as size) return `nil`. Treat a present `product_url` as the signal to navigate, and fall back to the regular option value selection flow when it's empty.

There's no Liquid property that reports whether a product is a combined listing parent or child. Branch on `product_url` instead.

***

## Examples

What combined listings are:

* **Parent product**: Can't be purchased, has no inventory, and is used for display.
* **Child products**: Purchasable products with their own inventory, images, and URLs.
* **Relationship**: Parent option values map to child products.
* **Example**: "Shoes" (parent) with children "Shoes - Red", "Shoes - Blue", and "Shoes - Black".
* **Limits**: Up to 2,000 variants across all child products, up to 60 child products per parent, and the parent can have three options.

### Problematic pattern: iterating all variants

```liquid
{%- for variant in product.variants -%}
  {{ variant.title }}
  {{ variant.image }}
  {{ variant.price | money }}
{%- endfor -%}
```

This loads every variant that the parent exposes, which is exactly the data you're trying to defer. It's also capped at 250 variants, so it silently renders an incomplete picker for large listings.

### Recommended pattern: render option values and carry the product URL

```liquid
<div class="option-value-selectors" data-section-id="{{ section.id }}">
  {%- for option in product.options_with_values -%}
    <fieldset>
      <legend>{{ option.name }}</legend>


      {%- for option_value in option.values -%}
        <input
          id="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}"
          type="radio"
          name="{{ section.id }}-{{ option.name }}-{{ option.position }}"
          value="{{ option_value | escape }}"
          {% if option_value.selected %}checked{% endif %}
          {% unless option_value.available %}disabled{% endunless %}
          data-option-value-id="{{ option_value.id }}"
          data-product-url="{{ option_value.product_url }}"
          onchange="onOptionValueChange(event)"
        />
        <label for="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}">
          {{ option_value }}
        </label>
      {%- endfor -%}
    </fieldset>
  {%- endfor -%}
</div>
```

Option values that belong to a child product render an empty `data-product-url`, which is the signal to re-render in place instead of navigating to a sibling product.

### Defer child product loading with the Section Rendering API

```liquid
<div class="product-info" id="product-info-{{ section.id }}" data-section-id="{{ section.id }}">
  <h1>{{ product.title }}</h1>
  <div class="price">{{ product.price | money }}</div>
</div>


<script>
  function onOptionValueChange(event) {
    const selectors = event.target.closest('.option-value-selectors');
    const sectionId = selectors.dataset.sectionId;
    const productUrl = event.target.dataset.productUrl;


    const selectedOptionValues = Array.from(
      selectors.querySelectorAll('input[type="radio"]:checked')
    ).map(({dataset}) => dataset.optionValueId);


    const params = selectedOptionValues.length > 0
      ? `&option_values=${selectedOptionValues.join(',')}`
      : '';


    // An empty product URL means the option value belongs to the current
    // product, so re-render the current page instead of a sibling product.
    const basePath = productUrl || window.location.pathname;


    fetch(`${basePath}?section_id=${sectionId}${params}`)
      .then((response) => response.text())
      .then((responseText) => {
        const html = new DOMParser().parseFromString(responseText, 'text/html');


        // Scope the update to the component that emitted the event. A bare
        // `#product-info` lookup always returns the first match in the document,
        // so a second product component on the page updates the wrong one.
        document.getElementById(`product-info-${sectionId}`).innerHTML =
          html.querySelector('.product-info').innerHTML;
        selectors.innerHTML = html.querySelector('.option-value-selectors').innerHTML;
      });
  }
</script>
```

You can pass the selected option value IDs in any order, and you can pass a partial set. Values that you don't provide fall back to the first available value for that option.

***

## Testing

* **Theme Inspector**: Check for child product queries in nested loops.
* **TTFB comparison**: Test a parent product with 20 or more child products.
* **Section rendering**: Verify that sibling products load correctly through Ajax, and that option values without a `product_url` still update availability.

***

## References

* [`product.options_with_values`](https://shopify.dev/docs/api/liquid/objects/product#product-options_with_values)
* [`product_option_value`](https://shopify.dev/docs/api/liquid/objects/product_option_value) object
* [`product_option_value.product_url`](https://shopify.dev/docs/api/liquid/objects/product_option_value#product_option_value-product_url)
* [`product_option_value.available`](https://shopify.dev/docs/api/liquid/objects/product_option_value#product_option_value-available)
* [`product_option_value.id`](https://shopify.dev/docs/api/liquid/objects/product_option_value#product_option_value-id)
* [`section.id`](https://shopify.dev/docs/api/liquid/objects/section#section-id)
* [`for`](https://shopify.dev/docs/api/liquid/tags/for) tag
* [`if`](https://shopify.dev/docs/api/liquid/tags/if) tag
* [About combined listings](https://shopify.dev/docs/apps/build/product-merchandising/combined-listings)
* [Build for combined listings](https://shopify.dev/docs/apps/build/product-merchandising/combined-listings/build-for-combined-listings)
* [Support high-variant products - Combined listings](https://shopify.dev/docs/storefronts/themes/product-merchandising/variants/support-high-variant-products#optional-supporting-combined-listings)
* [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)
* [Use the Section Rendering API for dynamic updates](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-rendering-api)

***
