Skip to main content

Build DOM for hidden components only when opened

Defer creating DOM nodes for initially hidden components until the user first requests them, rather than rendering them on page load.


Components that are initially hidden, such as filter panels, sub-filter facets, cart drawers, dialogs, and wishlist overlays, still add DOM nodes that the browser must account for during every style recalculation. When filter facets add 3,000 to 3,500 nodes to a collection page DOM, every user interaction on that page incurs higher presentation delay because the browser recalculates styles across all nodes before painting the response. This is true even when the filter panel is closed.

This DOM cost compounds when the same pattern exists across multiple components. The goal is to reduce the initial node count so that style recalculations triggered by any interaction are cheaper.


Anchor to Render-on-demand from a ,[object Object], tagRender-on-demand from a <template> tag

Store the component's HTML in a <template> element. Template content sits in a separate document fragment and isn't part of the live DOM, so it contributes zero nodes to style recalculation. On first interaction, inject the content:

const placeholder = document.querySelector("#filter-panel-placeholder");
const template = document.querySelector("#filter-panel-template");

toggleButton.addEventListener("click", async () => {
// Test `children.length`, not `hasChildNodes()`: a placeholder written across
// multiple lines holds a whitespace text node and always has child nodes.
if (placeholder.children.length === 0) {
const shell = template.content.cloneNode(true);
placeholder.appendChild(shell);
placeholder.hidden = false;

const sections = placeholder.querySelectorAll(".facet-group");
// For large components (1,000+ nodes), yield between chunks. Every group is
// initialized either way.
const yieldBetweenGroups = sections.length > 5;

for (const section of sections) {
if (yieldBetweenGroups) {
// `globalThis.` is required: a bare `scheduler?.yield()` throws a
// ReferenceError in browsers that don't define the global at all.
await (globalThis.scheduler?.yield?.() ??
new Promise((r) => setTimeout(r, 0)));
}
initializeFacetGroup(section);
}
} else {
placeholder.hidden = !placeholder.hidden;
}
});

For small components (under 200 nodes), the synchronous insertion is sufficient. For large components with independent sub-sections like facet groups, yielding between sections keeps the first-open interaction responsive.

When the HTML is generated server-side but shouldn't be in the live DOM initially, store it as a string in a data- attribute. The attribute doesn't add DOM nodes:

{%- capture filter_panel_html -%}
{%- for filter in collection.filters -%}
<div class="facet-group">
<h3>{{ filter.label }}</h3>
{%- for value in filter.values -%}
<label>
<input type="checkbox" name="{{ value.param_name }}" value="{{ value.value }}">
{{ value.label }}
</label>
{%- endfor -%}
</div>
{%- endfor -%}
{%- endcapture -%}

<div id="filter-panel-placeholder" data-html="{{ filter_panel_html | escape }}" hidden></div>
const placeholder = document.querySelector("#filter-panel-placeholder");

toggleButton.addEventListener("click", () => {
if (placeholder.dataset.html) {
placeholder.innerHTML = placeholder.dataset.html;
placeholder.removeAttribute("data-html");
placeholder.hidden = false;
} else {
placeholder.hidden = !placeholder.hidden;
}
});

Guard on placeholder.dataset.html, not on placeholder.hasChildNodes(). A placeholder <div> written across multiple lines contains a whitespace text node, so hasChildNodes() returns true on the first click and the panel never gets injected.

Anchor to Fetch-on-demand via the Section Rendering APIFetch-on-demand via the Section Rendering API

For Liquid-backed theme sections like cart drawers, fetch the section HTML on first interaction instead of rendering it on page load:

let cartDrawerLoaded = false;

cartIcon.addEventListener("click", async () => {
if (!cartDrawerLoaded) {
const response = await fetch(
`${window.location.pathname}?sections=cart-drawer`
);
const data = await response.json();
document.querySelector("#cart-drawer-placeholder").innerHTML =
data["cart-drawer"];
cartDrawerLoaded = true;
}
openCartDrawer();
});

This pattern applies only to Liquid-backed theme sections. For JS-generated DOM like filter facets built by third-party services, use the <template> or data- attribute patterns.


A collection page with 24 product cards and a fully rendered filter panel on load might have 8,000+ DOM nodes. Deferring the filter panel can reduce initial load to 4,500 to 5,000 nodes, reducing style recalculation cost on every card click, filter toggle, and add-to-cart interaction.


Measure DOM node count before and after in the DevTools console:

document.querySelectorAll("*").length;

Run this on initial page load before opening any components. A reduction of 2,000+ nodes on a collection page is a meaningful improvement. Follow up by measuring INP presentation delay in RUM data or using a DevTools trace with interaction recording.



Was this page helpful?