---
title: Send data requests for critical content before the DOM is ready
description: >-
  When JavaScript must render above-the-fold content from an external API, send
  the request as soon as the script executes and wait for the DOM only to
  render. Load the bundle async from the head so the request overlaps HTML
  parsing and Shopify's section rendering.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/request-critical-data-before-dom-ready
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/request-critical-data-before-dom-ready.md
api_name: liquid
---

# Send data requests for critical content before the DOM is ready

When a collection grid, filter panel, search results list, or reviews block must be rendered by JavaScript from an external API, send the request the moment the script executes. Wait for the DOM only to render the response. Load that script `async` from the `<head>`, above `{{ content_for_header }}` when it doesn't read Shopify's JavaScript globals, so it executes while the browser is still receiving the page.

[Render essential content in Liquid and HTML](https://shopify.dev/docs/storefronts/themes/best-practices/performance/render-essential-content-server-side) remains the first choice. This page is for the cases where the data lives outside Shopify and Liquid can't produce the markup.

***

## Why

A JavaScript component that renders critical content usually looks like this: the bundle loads, waits for `DOMContentLoaded`, finds its mount point, sends a request to the API, and renders when the response arrives. Each step waits for the previous one:

```text
HTML → bundle discovered → bundle downloaded and executed → wait for DOMContentLoaded → request → response → render
```

The wait for `DOMContentLoaded` is the problem. The request doesn't need the DOM. It needs a URL, an ID, a locale, and a page number, all of which are known before the body arrives. Sending it early removes one full network round trip from the path to content:

```text
HTML → bundle discovered → bundle downloaded and executed → request (parsing continues) → response → DOMContentLoaded → render
```

Shopify can [stream the HTML response](https://shopify.dev/docs/storefronts/themes/best-practices/performance/platform#streamed-html-responses), and on most pages it does. The browser receives the top of the `<head>` while Shopify is still rendering the sections, and `DOMContentLoaded` can't fire until those sections have arrived and been parsed. A bundle that's discovered in that first part and executes right away sends its request during the section render, which is often the longest single wait in the page. A bundle that waits for `DOMContentLoaded` sends it after that wait, and the response time is added on top.

The effect shows in [Largest Contentful Paint (LCP)](https://web.dev/lcp/) when the API-driven component holds the LCP element, which a collection grid or search results list usually does, and in [First Contentful Paint (FCP)](https://web.dev/fcp/) when the component is the first thing on the page.

***

## How

### Send the request when the script runs, not when the DOM is ready

Split the component into two phases. The first runs as soon as the script executes: build the request from information you already have and send it, keeping the promise. The second runs when the mount point exists: await the promise and render.

Before:

```javascript
document.addEventListener('DOMContentLoaded', async () => {
  const grid = document.querySelector('[data-collection-grid]');
  const response = await fetch(`https://search.example.com/collections/${grid.dataset.collectionId}`);
  render(grid, await response.json());
});
```

After:

```javascript
const {collectionHandle} = JSON.parse(document.getElementById('critical-data-config').textContent);


const data = fetch(`https://search.example.com/collections/${collectionHandle}`, {priority: 'high'})
  .then((response) => response.json());


const domReady =
  document.readyState === 'loading'
    ? new Promise((resolve) => document.addEventListener('DOMContentLoaded', resolve, {once: true}))
    : Promise.resolve();


Promise.all([data, domReady]).then(([json]) => {
  render(document.querySelector('[data-collection-grid]'), json);
});
```

The request now leaves the moment the script runs. The `critical-data-config` block that supplies `collectionHandle` is a JSON `<script>` placed above the bundle in the layout, covered in [Feed the request from Liquid](#feed-the-request-from-liquid-not-from-the-dom-or-shopify). If the response arrives before the DOM is ready, the promise holds it until the render step needs it. If the DOM is ready first, the render step waits for the response, which is the same wait as before minus the time the request would have spent queued behind `DOMContentLoaded`.

Check `document.readyState` rather than adding a `DOMContentLoaded` listener unconditionally. An `async` script doesn't delay `DOMContentLoaded`, so on a slow connection the bundle can arrive after the event has fired. A listener added at that point never runs and the grid stays empty. The `domReady` promise above resolves immediately in that case.

`priority: 'high'` asks the browser to fetch the request ahead of other background work. Browsers that don't support the option ignore it.

### Load the bundle `async` from the `<head>`

The first phase can only run early if the script executes early. Load it from the `<head>` with `async`, so the browser downloads it without blocking the parser and runs it as soon as it arrives:

## layout/theme.liquid

```liquid
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ page_title }}</title>


  {%- if request.page_type == 'collection' -%}
    <script src="{{ 'collection-grid.js' | asset_url }}" async></script>
  {%- endif -%}


  {{ 'base.css' | asset_url | stylesheet_tag }}


  {{ content_for_header }}
  ...
</head>
```

The layout renders on every page, so gate the script to the page types that contain the component. Use [`request.page_type`](https://shopify.dev/docs/api/liquid/objects/request#request-page_type) or [`template.name`](https://shopify.dev/docs/api/liquid/objects/template#template-name). Without the gate, every other page sends a request for content it can't show.

Place it above `{{ content_for_header }}`. On a streamed page, everything above that tag reaches the browser before Shopify has rendered the sections, so the script is discovered, downloaded, and executed during the wait. See [Load first-paint resources before `content_for_header`](https://shopify.dev/docs/storefronts/themes/best-practices/performance/load-critical-resources-before-content-for-header) for what else belongs there and what to check before moving anything.

Don't use `defer` for this script. Deferred scripts run after parsing finishes, which on a streamed page means after the sections have arrived, so the request waits for the same thing `DOMContentLoaded` did. `defer` is the right choice for the [non-critical scripts](https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-scripts) on the page, not for this one.

Don't inline the script with `async` on it. `async` and `defer` are ignored on inline scripts, and an inline script in the `<head>` waits for every stylesheet above it before it runs.

### Feed the request from Liquid, not from the DOM or `Shopify.*`

The request can only go before the DOM is ready if everything it needs is known when the script executes. Two sources of input break that:

* **The DOM.** Reading the collection ID from a `data-` attribute on the mount point means waiting for the mount point. Reading the current filter state from a form means waiting for the form.
* **`window.Shopify`.** `content_for_header` defines `Shopify.shop`, `Shopify.locale`, `Shopify.currency`, and `Shopify.routes.root`. Above `{{ content_for_header }}`, none of them exist yet, and the script throws.

Liquid knows all of these values at render time. Write them into the `<head>` before the script, as a JSON block or as global constants, and read them from there:

## layout/theme.liquid

```liquid
{%- if request.page_type == 'collection' or request.page_type == 'search' -%}
  <script type="application/json" id="critical-data-config">
    {
      "pageType": {{ request.page_type | json }},
      "collectionHandle": {{ collection.handle | json }},
      "searchTerms": {{ search.terms | json }},
      "locale": {{ request.locale.iso_code | json }},
      "currency": {{ cart.currency.iso_code | json }},
      "shopDomain": {{ shop.permanent_domain | json }}
    }
  </script>
  <script src="{{ 'collection-grid.js' | asset_url }}" async></script>
{%- endif -%}
```

## assets/collection-grid.js

```javascript
const config = JSON.parse(document.getElementById('critical-data-config').textContent);
```

The JSON block sits above the script in the document, so it's parsed before the script can run. The `json` filter escapes the values, so `search.terms` from the URL can't break out of the string. Prefer [`collection.handle`](https://shopify.dev/docs/api/liquid/objects/collection#collection-handle) over `collection.id` as the identifier: `/collections/all` has a handle but no ID, and `collection.id` is `nil` on the search page. Where a value can be `nil`, pass it through `json` (which prints `null`) or give it a `default`, so the block stays valid JSON.

Page state that lives in the URL, such as the page number, sort order, or filter parameters, is available from `location.search` without waiting for anything.

If the request needs something you can only get from `Shopify.*`, leave the script below `{{ content_for_header }}`. As an `async` script it still runs as soon as it downloads, which is usually before parsing finishes, so the request still tends to go out before `DOMContentLoaded`. The saving is smaller and less certain, but the pattern holds, and the `domReady` check keeps the render correct when the bundle arrives late.

### Render when the mount point exists

The render phase needs the mount point, and how you wait for it decides how early the content can paint.

The `domReady` promise above is the simplest signal. It resolves when the whole document has been parsed, so the render happens after the last section has arrived, even if the mount point was in the first one.

A custom element renders earlier. The browser upgrades a custom element when the parser reaches its tag, so a component defined as a custom element can render as soon as its own section has been parsed, without waiting for the rest of the document:

## assets/collection-grid.js

```javascript
const config = JSON.parse(document.getElementById('critical-data-config').textContent);


const data = fetch(`https://search.example.com/collections/${config.collectionHandle}${location.search}`, {
  priority: 'high',
})
  .then((response) => response.json());


class CollectionGrid extends HTMLElement {
  async connectedCallback() {
    render(this, await data);
  }
}


customElements.define('collection-grid', CollectionGrid);
```

## sections/main-collection.liquid

```liquid
<collection-grid class="collection-grid" style="display: block; min-height: 60vh"></collection-grid>
```

Keep the custom element empty. When the parser creates it, `connectedCallback` runs before any children have been appended, and if the request promise has already resolved, `await data` resumes in the next microtask, still before the children exist. A render that replaces the element's content at that point is followed by the parser appending the server-rendered children after it, and the result is duplicated or stale markup. If the element must contain server-rendered children, such as a Liquid-rendered first page, await `domReady` as well before rendering.

Either way, the component must not block first paint. The response may still be on its way when the mount point appears, so give the element a `min-height` that matches the content it'll hold. A custom element is `display: inline` by default and `min-height` has no effect on an inline box, so set `display: block` on it as well. See [Reserve space for app-injected content](https://shopify.dev/docs/storefronts/themes/best-practices/performance/reserve-space-app-injected) for the same problem on widgets you don't control.

### Keep the fallbacks in place

* If Liquid can render a first page of the content, render it. The JavaScript component then enhances a grid that's already visible rather than filling an empty box. See [Render essential content in Liquid and HTML](https://shopify.dev/docs/storefronts/themes/best-practices/performance/render-essential-content-server-side).
* If the bundle belongs to an app and you can't change when it sends its request, add a [`preconnect`](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-preconnect) hint for the API origin so at least the connection is ready when the request finally goes out.
* Apply this pattern to one or two requests that feed content above the fold. Everything else on the page follows the existing guidance: [defer it](https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-non-critical-resources) or [load it on interaction](https://shopify.dev/docs/storefronts/themes/best-practices/performance/load-javascript-on-user-interaction).

***

## Examples

### Example 1: Collection grid from an external search API

Before: the bundle is deferred and waits for `DOMContentLoaded` before it reads the collection ID from the DOM and sends the request.

## layout/theme.liquid

```liquid
<head>
  ...
  {{ content_for_header }}
  {{ 'base.css' | asset_url | stylesheet_tag }}
  <script src="{{ 'search-grid.js' | asset_url }}" defer></script>
</head>
```

## assets/search-grid.js

```javascript
document.addEventListener('DOMContentLoaded', async () => {
  const grid = document.querySelector('[data-search-grid]');
  const params = new URLSearchParams(location.search);
  params.set('collection', grid.dataset.collectionId);
  params.set('locale', window.Shopify.locale);


  const response = await fetch(`https://search.example.com/products?${params}`);
  renderGrid(grid, await response.json());
});
```

After: the inputs come from Liquid, the bundle is `async`, gated to collection pages, and sits above `{{ content_for_header }}`, and the request is sent before the DOM is ready.

## layout/theme.liquid

```liquid
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ page_title }}</title>


  {%- if request.page_type == 'collection' -%}
    <script type="application/json" id="search-grid-config">
      {
        "collectionHandle": {{ collection.handle | json }},
        "locale": {{ request.locale.iso_code | json }}
      }
    </script>
    <script src="{{ 'search-grid.js' | asset_url }}" async></script>
  {%- endif -%}


  {{ 'base.css' | asset_url | stylesheet_tag }}


  {{ content_for_header }}
  ...
</head>
```

## assets/search-grid.js

```javascript
const config = JSON.parse(document.getElementById('search-grid-config').textContent);
const params = new URLSearchParams(location.search);
params.set('collection', config.collectionHandle);
params.set('locale', config.locale);


const results = fetch(`https://search.example.com/products?${params}`, {priority: 'high'})
  .then((response) => response.json());


const domReady =
  document.readyState === 'loading'
    ? new Promise((resolve) => document.addEventListener('DOMContentLoaded', resolve, {once: true}))
    : Promise.resolve();


Promise.all([results, domReady]).then(([json]) => {
  renderGrid(document.querySelector('[data-search-grid]'), json);
});
```

### Example 2: Custom element that renders when it's parsed

The same request, with the render step attached to an empty custom element so it doesn't wait for the rest of the document:

## assets/search-grid.js

```javascript
const config = JSON.parse(document.getElementById('search-grid-config').textContent);
const params = new URLSearchParams(location.search);
params.set('collection', config.collectionHandle);
params.set('locale', config.locale);


const results = fetch(`https://search.example.com/products?${params}`, {priority: 'high'})
  .then((response) => response.json());


customElements.define(
  'search-grid',
  class extends HTMLElement {
    async connectedCallback() {
      renderGrid(this, await results);
    }
  },
);
```

## sections/main-collection.liquid

```liquid
<search-grid style="display: block; min-height: 60vh"></search-grid>
```

***

## Testing

* Open the **Network** panel in Chrome DevTools and reload a collection page on your live theme. The API request should start immediately after the bundle finishes downloading. On a streamed page, both should start while the document request is still in **Content download**. Before the change, the API request starts only after the document has finished downloading.
* In the **Performance** panel, record a page load and find the `DOMContentLoaded` marker. The API request should begin well before it. The render work for the component should follow whichever comes later, the marker or the response, rather than the sum of both.
* Run a [Lighthouse](https://developer.chrome.com/docs/lighthouse) audit before and after. LCP should improve when the component holds the LCP element.
* Load the page with the theme editor and with a normal storefront visit, and check the console. A script above `{{ content_for_header }}` that still reads `Shopify.*` throws `ReferenceError: Shopify is not defined` on the storefront only.
* Load the home page, a product page, and a search page with the **Network** panel open. No request to the API should appear on pages that don't contain the component.
* Throttle the network to **Slow 3G** in the **Network** panel and reload. The grid must still render when the bundle arrives after `DOMContentLoaded` has fired.

***

## References

* [Render essential content in Liquid and HTML, not JavaScript](https://shopify.dev/docs/storefronts/themes/best-practices/performance/render-essential-content-server-side)
* [Load first-paint resources before `content_for_header`](https://shopify.dev/docs/storefronts/themes/best-practices/performance/load-critical-resources-before-content-for-header)
* [The Shopify platform: Streamed HTML responses](https://shopify.dev/docs/storefronts/themes/best-practices/performance/platform#streamed-html-responses)
* [Warm up third-party connections early with `preconnect`](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-preconnect)
* [Reserve space for app-injected content](https://shopify.dev/docs/storefronts/themes/best-practices/performance/reserve-space-app-injected)
* [Use `defer` and `async` on non-critical scripts](https://shopify.dev/docs/storefronts/themes/best-practices/performance/defer-scripts)
* [`json`](https://shopify.dev/docs/api/liquid/filters/json) filter
* [`fetch()` `priority` option](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#priority) on MDN
* [Using custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements) on MDN

***
