What You'll Learn
- Why widgets that inject themselves with JavaScript after page load are a direct cause of Cumulative Layout Shift (CLS) on Shopify product pages.
- How to build a server-side-rendered (SSR) visibility gate in Liquid that predicts whether a widget will show, before a single byte of JavaScript runs.
- How to reserve the exact space the widget needs with a responsive skeleton, so hydration causes near-zero shift on both mobile and desktop.
- How to defer the widget's JavaScript past the Largest Contentful Paint without re-introducing shift.
JS-injected storefront widgets are one of the most common and least obvious causes of a failing CLS score on Shopify. The widget loads its script after paint, decides client-side whether it should appear, then inserts a block of content that pushes everything below it down the page. Google rates CLS as "good" only at 0.1 or less, and "poor" above 0.25 (web.dev, Cumulative Layout Shift). A single late-injected discount table can blow past that on its own.
This guide documents the pattern we use in the Flicky theme app extension to render Volume Discount and related widgets with near-zero layout shift: an SSR visibility gate that reserves space in Liquid before paint, a responsive skeleton that matches the widget's real height at every viewport, and a deferred-hydration step that fills the reserved box after the Largest Contentful Paint (LCP). Every code sample below is the approach as it actually ships, not a toy example.
Why JS-Injected Widgets Cause Layout Shift
Layout shift happens when a visible element changes position between frames without a user interaction. The classic Shopify app pattern creates exactly that: the theme loads, the browser paints the product page, and only then does the app's deferred script run, check its conditions, and inject a widget into the DOM. The browser has to reflow the page to make room, and everything below the insertion point jumps.
The naive fixes all fail in their own way:
- Rendering the widget synchronously blocks the critical path and hurts LCP, trading one Core Web Vital for another.
- Reserving a fixed pixel height (
min-height: 300px) is desktop-biased. A variant table that is a compact 52px-per-row grid on desktop becomes a ~190px stacked card per variant on mobile, so a desktop-tuned reserve under-reserves on phones and the page still shifts. - Reserving space for every product adds dead whitespace to pages where the widget will never show, because the eligibility rules (active window, product targeting, customer segment) are not evaluated until the script runs.
The root cause is a timing mismatch: the decision about whether and how big the widget is happens client-side, after paint. The fix is to move as much of that decision as possible to the server, where Liquid runs before the browser paints anything.
What We're Building
The pattern has three parts working together:
- A Liquid gate snippet that evaluates the server-knowable subset of the widget's eligibility rules and echoes the number of rows to reserve (or
0for no match). - A skeleton placeholder rendered inline in the app embed block, styled responsively so it occupies the widget's true height on every viewport.
- A deferred loader that activates the widget's real JavaScript after LCP, hydrates it into the reserved box, and drops any box the server over-reserved.
Here is the flow, server to client:
Prerequisites
You'll need:
- A Shopify theme app extension with an app embed block and/or app block.
- Widget configuration stored in an app-owned metafield (we read
app.metafields.flicky.volume_discounts.value, a JSON array of rules). - Comfort with Liquid control flow and a bundler (we use Vite) that emits the widget entry points.
- Basic familiarity with Core Web Vitals and Chrome DevTools.
Tested on: Shopify Online Store 2.0 themes, theme app extension blocks targeting body (app embed) and section (app block).
A note on terminology: an app embed block (target head, compliance_head, or body) renders once per page and is ideal for global setup; an app block (target content) is placed by the merchant in a specific slot (Shopify theme app extension configuration). We use both.
Step 1: Build an SSR Visibility Gate in Liquid
In this step you'll write a Liquid snippet that answers one question before paint: how many rows should I reserve for this product and customer? It returns an integer (the row count) or 0 when no rule matches.
The key insight is that Liquid already knows most of what the widget needs. The active window (start_at/end_at), product include/exclude lists, and customer login state and tags are all available server-side. Only a few conditions (customer segments, markets, variant-specific targeting) require a client call. We evaluate the server-knowable subset exactly, and treat the client-only conditions optimistically: reserve the space, then let JavaScript refine. Reserving and then removing a box does not cause a downward shift of content below it in the same way a late insertion does, so optimism is the safe direction.
{%- comment -%}
snippets/flicky-widget-gate.liquid
Echoes the number of rows to reserve for the FIRST rule matching this
product + customer at the given placement, or 0 when none match.
Output MUST be ONLY the integer (callers coerce with `| plus: 0`).
Params: rules (metafield array), feature ('vd'|'vt'), position ('block'|'auto').
{%- endcomment -%}
{%- liquid
assign reserve = 0
assign default_pos = 'block'
if feature == 'vd'
assign default_pos = 'auto'
endif
assign now_ts = 'now' | date: '%s' | plus: 0
for rule in rules
assign rpos = rule.position | default: default_pos
if rpos != position
continue
endif
# Active window: [start_at, end_at)
if rule.start_at != blank
assign start_ts = rule.start_at | date: '%s' | plus: 0
if now_ts < start_ts
continue
endif
endif
if rule.end_at != blank
assign end_ts = rule.end_at | date: '%s' | plus: 0
if now_ts >= end_ts
continue
endif
endif
# Customer eligibility: logged-in / tags checked here;
# segments + markets are optimistic (reserve, refine in JS).
assign elig = rule.eligibility
assign ok_customer = true
if elig != blank and elig.type == 'logged_in' and customer == blank
assign ok_customer = false
endif
if ok_customer == false
continue
endif
# Match -> number of rows so the skeleton renders the right line count.
# Row HEIGHT is handled by responsive CSS, not a px estimate here.
if feature == 'vd'
assign reserve = rule.tiers.size | default: 3 | at_most: 8
else
assign reserve = product.variants.size | at_most: 20
endif
break
endfor
echo reserve
-%}What just happened: the snippet mirrors the client matcher's logic (active-window.js, the product include/exclude check) closely enough to predict the outcome, and returns a count, not a pixel height. Capping the count (at_most: 8 for tiers, at_most: 20 for variants) protects against a rule that would otherwise reserve a massive DOM.
Watch out: the snippet must output only the integer. Two gotchas bit us here. First, a
{% doc %}header can leak its text to the storefront even whenvalidate_themepasses, so use{%- comment -%}instead. Second,renderruns in an isolated scope with noappobject, so passrulesin as a parameter;productandcustomerare global and remain available.
Step 2: Reserve Space With a Responsive Skeleton
In this step you'll render a skeleton placeholder whose height matches the real widget at every viewport, so the swap in Step 4 causes no shift.
First, the critical decision: reserve by row count and CSS, not by a fixed pixel height. The gate gives us the number of rows; responsive CSS gives each row the right height for the current viewport. This is what defeats the mobile/desktop mismatch that sinks pixel-based reserves.
The skeleton styles live in an inline <style> tag inside the app embed block. This is deliberate: theme app extension blocks cannot use the {% stylesheet %} tag the way theme snippets can, and the app embed renders once before the widgets, so an inline style block is available at first paint without a render-blocking external request.
{%- comment -%} blocks/main.liquid (app embed, target: body) {%- endcomment -%}
<style>
.flicky-skel { display: flex; flex-direction: column; gap: 10px; padding: 4px 0; }
.flicky-skel__head { height: 20px; width: 55%; border-radius: 6px; }
.flicky-skel__foot { height: 44px; border-radius: 10px; }
.flicky-skel__row { height: 48px; border-radius: 10px; }
/* Volume Discount: tier row is stable across viewports. */
.flicky-skel--vd .flicky-skel__row { height: 74px; }
/* Variant table: compact table row on desktop. */
.flicky-skel--vt .flicky-skel__row { height: 52px; }
/* Variant table on mobile becomes a stacked card, ~190px per variant. */
@media (max-width: 640px) {
.flicky-skel--vt .flicky-skel__row { height: 190px; }
}
.flicky-skel > * {
background: linear-gradient(100deg, #eceef1 30%, #f5f6f8 50%, #eceef1 70%);
background-size: 200% 100%;
animation: flicky-skel-shimmer 1.3s ease-in-out infinite;
}
@keyframes flicky-skel-shimmer {
0% { background-position: 200% 0; } 100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) { .flicky-skel > * { animation: none; } }
</style>Then, in the app block, call the gate, coerce its output to an integer, and render one skeleton row per reserved row:
{%- comment -%} blocks/volume-discount.liquid (app block, target: section) {%- endcomment -%}
{%- assign flicky_product = block.settings.product | default: section.settings.product | default: product -%}
{%- capture flicky_vd_count -%}
{%- render 'flicky-widget-gate', rules: app.metafields.flicky.volume_discounts.value, feature: 'vd', position: 'block', product: flicky_product -%}
{%- endcapture -%}
{%- assign flicky_vd_count = flicky_vd_count | plus: 0 -%}
<div class="flicky-vd-block" data-flicky-vd-block {{ block.shopify_attributes }}>
{%- if flicky_vd_count > 0 -%}
<div class="flicky-skel flicky-skel--vd" data-flicky-skel aria-hidden="true">
<div class="flicky-skel__head"></div>
{%- for i in (1..flicky_vd_count) -%}<div class="flicky-skel__row"></div>{%- endfor -%}
<div class="flicky-skel__foot"></div>
</div>
{%- endif -%}
</div>What just happened: the {%- capture -%} plus | plus: 0 pattern is load-bearing. Interpolating the render output directly into a JavaScript object literal produced a SyntaxError and a cascade of "undefined" failures in production, because any stray whitespace in the snippet output broke the literal. Capturing the output and coercing it to an integer makes the value safe to use anywhere.
Step 3: Defer the Widget JavaScript Past LCP
In this step you'll move the widget's scripts out of the critical path so they don't delay first paint, while keeping the skeleton reserve intact.
Shopify auto-injects an app block's javascript and stylesheet assets when the block is present on the page, but you have more control if you wrap the asset tags in an inert <template> and activate them yourself after LCP. A <template>'s contents are parsed but not loaded, so nothing fetches until you clone the nodes into the document. Shopify's own guidance is to defer non-essential scripts and then "visually inspect the page to confirm that your changes haven't introduced FOUC or new layout shifts" (Shopify, Defer render-blocking CSS and JavaScript). The skeleton is what makes that inspection pass.
{%- comment -%} Wrap the Vite-emitted tags so the browser loads nothing yet. {%- endcomment -%}
<template class="flicky-deferred-assets">{%- render 'vite-tag' with 'main.js' -%}</template><script>
(function () {
function activate() {
var tpls = [].slice.call(document.querySelectorAll('template.flicky-deferred-assets'));
if (!tpls.length) return;
// Append <link> first so CSS downloads in parallel.
tpls.forEach(function (t) {
t.content.querySelectorAll('link').forEach(function (l) {
document.head.appendChild(l.cloneNode(true));
});
});
// Re-create <script> nodes (cloned template scripts do not execute).
tpls.forEach(function (t) {
t.content.querySelectorAll('script').forEach(function (o) {
var s = document.createElement('script');
if (o.src) s.src = o.src;
if (o.type) s.type = o.type;
if (!o.src) s.textContent = o.textContent;
document.head.appendChild(s);
});
});
tpls.forEach(function (t) { t.remove(); });
}
function schedule() {
if ('requestIdleCallback' in window) requestIdleCallback(activate, { timeout: 2000 });
else setTimeout(activate, 200);
}
if (document.readyState === 'complete') schedule();
else window.addEventListener('load', schedule);
// Theme editor: activate when a new block is dropped in.
document.addEventListener('shopify:section:load', activate);
})();
</script>What just happened: scripts now download and run during idle time after load, so they are off the critical path for FCP and LCP. Because the skeleton already holds the space, the user sees a stable layout the whole time, then the widget fills in. One subtlety worth copying: a cloned <script> from a template does not execute, so you must create a fresh <script> node and copy src/type/textContent across.
Step 4: Hydrate in Place and Clean Up
In this step you'll swap the skeleton for the real widget in the same DOM node, and remove any box the server optimistically over-reserved.
When the deferred entry point runs, it finds the reserved box, removes the skeleton children, and renders the widget into the exact same container. Same box, same position, no shift.
// frontend/entrypoints/volume-discount-block.js
const mount = document.querySelector('[data-flicky-vd-block]');
if (mount && matches.length) {
// Skeleton already holds the right space; widget takes its place -> CLS ~0.
mount.querySelectorAll(':scope > [data-flicky-skel]').forEach((s) => s.remove());
for (const discount of matches) {
const container = document.createElement('div');
// ... render the real tier widget into container ...
mount.appendChild(container);
}
}For the optimistic reserves (segments, markets, variant-specific rules), the client may discover the widget should not show after all. In that case, collapse the leftover box so it does not linger as empty space:
// frontend/entrypoints/main.js
function dropUnhydratedReserves() {
document.querySelectorAll('[data-flicky-auto-vd] > [data-flicky-skel]')
.forEach((s) => s.closest('[data-flicky-auto-vd]')?.remove());
}
// Run only AFTER async refinement (segments fetch) resolves, so you never
// collapse a box that was about to hydrate.
await initProductPage();
dropUnhydratedReserves();What just happened: removing a reserved box shifts content up, not down, and it happens during idle time rather than after a late insertion. The net experience is a brief skeleton that either becomes the widget or quietly disappears, with no jarring jump either way. One ordering rule matters: run the cleanup only after any asynchronous refinement resolves, or you will collapse a box that was one tick away from hydrating.
Testing and Verification
Verify the pattern with the same tools Shopify recommends for deferral work.
Quick check in Chrome DevTools:
- Open the product page, open DevTools, and go to the Performance panel.
- Record a reload with CPU and network throttling set to a mid-tier mobile profile.
- Inspect the Experience track for layout-shift markers around the moment the widget appears.
Manual verification checklist:
- With the widget eligible: the skeleton appears before paint and the widget replaces it in the same spot, with no visible jump.
- With the widget ineligible: no skeleton is reserved (the gate returned
0), so there is no empty space. - On a 375px-wide viewport: the reserved height matches the stacked-card widget height, not the desktop table height.
- With an optimistic reserve that the client rejects: the box collapses during idle time, not after a long delay.
- Lighthouse CLS stays at 0.1 or below (web.dev, CLS).
Measure on a representative storefront before and after enabling the pattern; the exact delta depends on the theme, the product mix, and how many rules target a given page.
Troubleshooting
These are the real failures we hit building this, with the fix for each.
| Problem | Symptom | Fix |
|---|---|---|
| Snippet output used inline in JS | SyntaxError: Unexpected identifier, flickyDiscount is not defined |
Wrap the render call in {%- capture -%} and coerce with ` |
{% doc %} header in the gate snippet |
Raw doc text printed on the storefront (even though validate_theme passed) |
Use {%- comment -%} so the snippet emits only the integer |
{% stylesheet %} in an app extension block |
validate_theme rejects the block |
Put skeleton CSS in an inline <style> inside the app embed block |
Fixed min-height reserve |
Page still shifts on mobile | Reserve by row count and let responsive CSS set row height per viewport |
| Cleanup runs too early | Eligible widget's box disappears before it hydrates | Await async refinement (segments fetch) before dropUnhydratedReserves() |
Cloned template <script> never runs |
Deferred assets never activate | Create a fresh <script> node and copy src/type/textContent |
Key Takeaways
1. Move the visibility decision to the server. The single biggest win is predicting, in Liquid, whether and how big the widget will be. Everything else follows from having that answer before paint.
2. Reserve by count, render height with CSS. Pixel reserves encode one viewport's assumptions. A row count plus responsive CSS reserves correctly on every screen, which is where fixed-height reserves quietly fail.
3. Be optimistic about client-only conditions. Reserving and then collapsing a box is cheaper, in CLS terms, than inserting a box late. Reserve when unsure, refine in JavaScript, and clean up during idle time.
4. Defer is safe only once space is held. Deferring scripts past LCP is the right move for load speed, but on its own it guarantees a late insertion. The skeleton is what lets you defer without paying for it in CLS.
Frequently Asked Questions
Does this hurt my LCP while helping CLS?
No. The widget scripts load during idle time after the load event, so they stay off the critical path for first paint and LCP. The skeleton is plain HTML and a small inline style block rendered by the app embed, which adds negligible weight before paint.
Why not just render the whole widget server-side in Liquid?
Some of the widget's logic (customer segments, market-specific pricing, variant-level targeting) is not available to Liquid without extra calls, and the interactive parts (quantity selection, live price updates) need JavaScript anyway. The gate renders the reservation server-side and leaves the interaction to the client, which is the split that keeps both Core Web Vitals healthy.
How do I reserve the right height when the mobile and desktop layouts differ?
Reserve a number of rows, not a pixel height, and give each skeleton row its height through responsive CSS that mirrors the widget's own breakpoints. In our variant table the row is 52px on desktop and about 190px on mobile, so the skeleton uses the same @media (max-width: 640px) rule the widget does.
What happens if the server guesses wrong about eligibility?
The gate only over-reserves for client-only conditions, never under-reserves for server-knowable ones. If the client decides the widget should not show, the reserved box is collapsed during idle time, which shifts content up rather than down and happens before the user is likely to have scrolled to it.
Written by Duke, based on the production implementation of the Flicky Shopify theme app extension. Code samples are drawn from the shipping extension and simplified for readability.