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 remains the first choice. This page is for the cases where the data lives outside Shopify and Liquid can't produce the markup.
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:
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:
Shopify can stream the HTML response, 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) 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) when the component is the first thing on the page.
Anchor to Send the request when the script runs, not when the DOM is readySend 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:
After:
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. 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.
Anchor to Load the bundle ,[object Object], from the ,[object Object]Load the bundle async from the <head>
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
The layout renders on every page, so gate the script to the page types that contain the component. Use request.page_type or 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 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 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.
Anchor to Feed the request from Liquid, not from the DOM or ,[object Object]Feed the request from Liquid, not from the DOM or Shopify.*
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_headerdefinesShopify.shop,Shopify.locale,Shopify.currency, andShopify.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
assets/collection-grid.js
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 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.
Anchor to Render when the mount point existsRender 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
sections/main-collection.liquid
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 for the same problem on widgets you don't control.
Anchor to Keep the fallbacks in placeKeep 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.
- If the bundle belongs to an app and you can't change when it sends its request, add a
preconnecthint 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 or load it on interaction.
Anchor to ExamplesExamples
Anchor to Example 1: Collection grid from an external search APIExample 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
assets/search-grid.js
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
assets/search-grid.js
Anchor to Example 2: Custom element that renders when it's parsedExample 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
sections/main-collection.liquid
Anchor to TestingTesting
- 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
DOMContentLoadedmarker. 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 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 readsShopify.*throwsReferenceError: Shopify is not definedon 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
DOMContentLoadedhas fired.
Anchor to ReferencesReferences
- Render essential content in Liquid and HTML, not JavaScript
- Load first-paint resources before
content_for_header - The Shopify platform: Streamed HTML responses
- Warm up third-party connections early with
preconnect - Reserve space for app-injected content
- Use
deferandasyncon non-critical scripts jsonfilterfetch()priorityoption on MDN- Using custom elements on MDN