15 min read
Storefront performance
The storefront is built for a mid-range Android phone on 4G. The targets are Google’s “good” Core Web Vitals at the 75th percentile:
| Metric | Target | What moves it here |
|---|---|---|
| LCP (Largest Contentful Paint) | under 2.5 s | the hero or first product image, fonts, render-blocking JS |
| INP (Interaction to Next Paint) | under 200 ms | how much JS hydrates and runs on the main thread |
| CLS (Cumulative Layout Shift) | under 0.1 | images without a reserved box, font swaps, late-injected widgets |
This page is the checklist for anyone adding a block, a client component or a third-party script. Every rule below exists because breaking it showed up in the build output or in a Lighthouse run.
Measuring first-load JS
Next 16 no longer prints “First Load JS” in next build. The repo has a script that reads
the same facts from the build output:
cd apps/storefront && bun run build # needs no database; env vars are optional
cd ../.. && bun scripts/perf/first-load-js.ts apps/storefront # table
bun scripts/perf/first-load-js.ts apps/storefront --json # for diffsIt adds up the gzip size of the runtime chunks every page loads (build-manifest.json
rootMainFiles) and the entry chunks of each route’s layouts and page (the entryJSFiles in
its page_client-reference-manifest.js). Chunks fetched later with import() are not
counted, which is the point.
The September 2026 speed pass took every store route from about 306 kB to 167 kB gzip (132 kB of that is React and the Next runtime, which every page pays). Run the script before and after any change that adds a client component.
Why one import can cost every page
Turbopack puts every client component a route segment imports into one chunk group,
whether or not the page renders it. The store layout imports @commerce/blocks and the Puck
config, so every section’s client code ships on every page. Two consequences:
- Keep heavy libraries out of client modules’ static imports. A
'use client'file that importsmotion,zodor a large SDK puts that library on every storefront page. - Server components are free. Anything without
'use client'costs no JS. Prefer rendering on the server and passing plain props down to a small client leaf.
Rules for client code
No zod in the browser
zod alone was about 90 kB gzip on every page, pulled in by the consent banner, the theme
preview bridge and the preview bar through @commerce/core subpaths that also define
schemas. Client components import from zod-free subpaths instead:
| Need | Import from | Not from |
|---|---|---|
consent cookie, allowedTags | @commerce/core/consent | @commerce/core/preferences |
THEME_PREVIEW_PARAM, preview messages | @commerce/core/theme-preview | @commerce/core/theme |
| payment adjustment maths and labels | @commerce/core/adjustment-math | @commerce/core/adjustments |
| gift card code normalising | @commerce/core/gift-card-code | @commerce/core/gift-cards |
| PIN code and mobile patterns | @commerce/core/account-patterns | @commerce/core/accounts |
| return reasons and labels | @commerce/core/return-reasons | @commerce/core/returns |
| EMI offer for a price | @commerce/plugin-emi/offer-for | @commerce/plugin-emi/offer |
Each original module re-exports the split-out one, so server code keeps a single import.
import type from anywhere is fine: types are erased.
When a client component genuinely needs a schema, load it on demand. The theme preview bridge
does this: it fetches @commerce/core/theme (schema and CSS builder) with import() only
after the admin’s preview token verifies, inside the editor’s frame. No shopper downloads it.
Motion loads lazily
Blocks use Motion’s vanilla API through packages/blocks/src/lazy-motion.ts:
import { loadMotion } from '../lazy-motion'
useEffect(() => {
if (reducedMotion) return
let live = true
loadMotion()
.then(({ animate }) => {
if (live) animate(el, { rotate: 360 }, { duration: 6, repeat: Infinity, ease: 'linear' })
})
.catch(() => {})
return () => { live = false }
}, [reducedMotion])- Never
import { motion } from 'motion/react'orimport { animate } from 'motion'in a block. The library is about 48 kB gzip, fetched once after hydration and shared by every block that asks. - For reduced motion, use
useReducedMotionfrompackages/blocks/src/use-reduced-motion.ts(auseSyncExternalStoreover the media query). It answersfalseduring hydration and corrects right after, so it never causes a hydration mismatch. - The server HTML is the resting state. Motion only adds the entrance or the loop, so a slow or failed fetch never hides content. Reveals hide a section only if it is still below the fold when Motion arrives; the LCP element is never hidden.
preloadMotionWhenIdle()(used by the mobile menu) starts the fetch afterload, when the browser is idle, so the first tap rarely waits on the network.
Hydrate less
SectionFrame renders the plain wrapper itself when a section has no entrance animation (the
default), instead of mounting SectionReveal for every section on the page. Fewer client
components means less hydration work, which is what INP measures on a slow CPU.
Images
Everything product and section images render goes through <Picture>
(packages/blocks/src/components/picture.tsx): AVIF and WebP srcSet at the pipeline’s
widths (see Image pipeline), the ThumbHash as the
<img>’s background while it loads, and the original URL as the fallback.
- Section images get variants at render time. The theme editor saves image settings as
{ url, alt, focal }.renderTemplatecollects every image URL in a template’s section props, looks them up inmediain one query under the store’s RLS (apps/storefront/src/lib/media-lookup.ts, pure part insection-images.ts), and addsvariantsandthumbhashbefore the sections render. URLs the library does not know (external links, legacy uploads) render as before. - Product images select
variantsandthumbhashingetProductandlistCardProducts. Gallery thumbnails and predictive search results use the 320 px WebP variant, not the original. fillis for images sized by their container (width/height: 100%,absolute inset-0, CSSaspect-ratio). It gives the<picture>wrapperdisplay: contents, so percentages resolve exactly as they did against a bare<img>. Inline, because the blocks stylesheet’s unlayeredpicture { display: block }beats utility classes.placeholder={false}forobject-fit: containimages (the featured product), where the ThumbHash would show in the letterbox.- Priority. Only the hero and the first product image load eagerly with
fetchpriority="high": the first hero carousel slide, the product gallery’s first image, and the first section of a page template.renderTemplatepasses_aboveFold: trueto that section, and hero-type blocks (image banner, split hero) honour it. Everything else isloading="lazy". - Reserved space. Every image has either width and height attributes or a CSS box
(
aspect-ratio, fixed height, absolute fill). The header logo gets aheightfrom the media row’s real size, so the header does not grow when the logo arrives.
Fonts
apps/storefront/src/lib/fonts.ts self-hosts every theme face through next/font/google:
Latin subset, display: swap, and next/font’s metric-matched fallback (Archivo Fallback,
Figtree Fallback: Arial with size-adjust and ascent/descent overrides), so the swap does not
move text. Only the default pairing’s upright faces are preloaded (Archivo, Figtree normal);
Figtree italic is a separate, non-preloaded declaration under the same family name, and every
other theme font downloads only when a theme uses it.
Third-party scripts
| Script | When it loads |
|---|---|
| GA4, Tag Manager, Meta pixel | next/script lazyOnload: after load, when the browser is idle, and only with consent |
| Razorpay Affordability Widget | afterLoadWhenIdle (packages/blocks/src/idle.ts), then Razorpay’s script |
Razorpay Checkout (/checkout/pay) | on that page only, immediately: the shopper is there to pay |
There is no chat widget today. When one is added, load it the same way (lazyOnload, or on
first interaction) and never in the document head.
Imported themes: weight and streaming
An imported Shopify theme’s page is mostly the theme’s own Liquid markup (a Zero Drift home page is about 360 kB of it). Three things keep that from costing more than it must.
The markup is sent once
A server component’s props are serialised into the RSC payload, which Next inlines in the page
for hydration. Theme markup passed as a prop was therefore in the page twice: as the DOM, and
again, escaped, in the payload. Now LiquidHtml (components/theme/liquid-html.tsx) parks the
markup in a per-process slot (lib/liquid-slots.ts) and renders the client component
LiquidSlot with only the slot id; the HTML render of LiquidSlot, in the same process, takes
the markup back out and writes it into the page. In the browser, LiquidSlot renders an element
with dangerouslySetInnerHTML whose content React never reads during hydration, so the server’s
markup stays exactly as it is and the payload carries no copy.
- Requests with no HTML render (a client navigation, a prefetch, a router refresh, a server
action) carry the markup inline, because the browser has nothing to keep. Next hides its
RSCheader fromheaders()and from the proxy, soflightOnlyRequest(lib/flight-request.ts) tells them apart byNext-ActionandSec-Fetch-Dest(the router usesfetch, soempty). - Updates: each slot also carries a digest of its markup without the request’s nonce. A later render replaces the element’s markup only when the digest changed (a cart count after an action), never for a new nonce, so the theme’s scripts keep what they bound.
- After a hydration error, React rebuilds the element on the client;
LiquidSlotthen reads the markup from the server’s element, which is still in the document at that moment. - A slot nobody takes (an aborted render) is dropped after a minute.
The first sections first, the rest streamed
renderTemplate splits an imported theme’s page (lib/liquid-split.ts): the first
STOREFRONT_EAGER_SECTIONS sections (default 3) are rendered before the page starts streaming;
the rest are rendered after them. When that takes longer than 25 ms (a cache miss), they stream in
in the same response, behind one Suspense boundary (components/theme/deferred-liquid.tsx),
and the shopper sees the top of the page while the rest is still rendering. When the render cache
already holds them they come back at once and are drawn in place: streaming would gain nothing
and would only make React move them into the page afterwards. Nothing is fetched on scroll, so nothing is hidden from
crawlers, and crawlers (Next’s user-agent check) get every section in place, in order, with no
streaming. The header and footer groups and theme previews are never split.
Theme scripts wait for the streamed sections: LiquidBoot boots the theme only once the page is
parsed and React has put every streamed part in place (no template[id^="B:"] left and React’s
reveal queue empty; React batches reveals for up to about 300 ms, so DOMContentLoaded alone
is not enough).
Below the fold waits for the shopper
Sections from position STOREFRONT_EAGER_SECTIONS on are rewritten once, before they are cached
(lib/liquid-fold.ts):
| What | Change |
|---|---|
<img>, <iframe> | loading="lazy"; loading="eager" and fetchpriority="high" removed |
<video> | preload="none"; autoplay becomes data-theme-autoplay, played when near |
| the section’s parked scripts | data-theme-near: run when the section comes within a screen of the viewport |
The first sections are left as the theme wrote them, so the theme decides its own hero image
priority. The shim (lib/liquid-shim.ts) runs the near scripts in document order per section
through an IntersectionObserver (rootMargin: 100%); a section with no box (hidden, a popup)
starts at once.
Caching
Store pages render per request (the host, the cart cookie and the CSP nonce differ), but almost nothing they draw changes between requests. The render cache keeps it in the storefront process until the store changes.
What is cached
| Cached | Key (besides the store and its version) | Code |
|---|---|---|
| An imported theme’s rendered sections | theme id + files version, origin, path, template, posted form, a digest of the section’s settings | lib/liquid-theme.ts, lib/liquid-cache.ts |
| An imported theme’s layout (head, icon sprites, cart drawer) | the same, without the section | liquidLayoutFor |
| Theme data Liquid reads (products, collections, menus, pages, blogs, extra fields, content entries) | origin + the read and its arguments | cachedLiquidData |
| Templates, loops, connected sources, section images, the app block list | template key and suffix, a digest of the bound paths | lib/template-render.tsx |
| Layout data (store row, menus, policies, theme settings, logo size, collections) | none | [storeId]/layout.tsx |
| Product page data, reviews, EMI and back-in-stock settings | handle or product id | products/[handle]/page.tsx, lib/reviews.ts |
| Collection and search results | handle (or terms), filters, sort and page | collections/[handle]/page.tsx, search/page.tsx |
Never cached: the cart and anything that reads it for a shopper with items (the header
count, the cart drawer), the signed-in customer, checkout, account pages, a theme preview
(drafts, ?theme-version=), and the verified-buyer part of reviews. A Liquid section that reads
the cart is cached only as rendered for an empty cart: a shopper with items gets their own
render. A section that reads the clock ('now' | date) lives 60 seconds; a section that failed
to render, 30 seconds.
The CSP nonce: Liquid renders write a random per-process placeholder where the nonce goes, and
each request swaps in its own (withNonce). Theme data cannot contain the placeholder, so a
script that came in with data never gains a nonce.
Cached values are shared between requests: treat what storeRead and cachedForStore return as
read-only. And call them outside inStore: on a miss they open their own transaction, and a
second connection taken inside one can wait forever on a pool that busy requests have filled.
Invalidation: every write, the moment it commits
Migration 0115_storefront_cache_notify puts a trigger on every table a page renders
(STOREFRONT_CACHE_TABLES in packages/db/src/storefront-cache.ts). Each insert, update or
delete sends NOTIFY storefront_cache, '<store id>' when its transaction commits (Postgres sends
a repeated payload once per transaction, so a bulk import is one message). The storefront LISTENs
on one dedicated connection (listenStorefrontCache) and bumps that store’s version; every
cache key carries it, so the next request reads fresh data. Admin saves, the worker, importers,
scripts and orders (stock) all go through the same trigger; nothing has to remember to
invalidate.
| Write | Effect |
|---|---|
Products, variants, options, stock (inventory_level), collections, menus, pages, blogs, articles, extra fields, content entries, reviews, discounts, shipping, locations, domains, redirects, theme files, templates, theme publish, store settings | that store’s cache, at once |
media (the worker finishing an image) | at most once a minute per store (:soft payload), so an image backlog does not empty the cache every few seconds |
| The store row’s order counter or the admin accent colour | nothing |
Carts, orders, customers, admin-only tables (STOREFRONT_CACHE_EXEMPT, each with its reason) | nothing |
Changes that write nothing are covered too: the store’s next scheduled change (a product’s
launch_at, an article’s published_at, a discount starting or ending,
nextScheduledChange in core) ends the version when it passes. Every entry also expires after
an hour, the net for anything no trigger sees (a collection sorted by best selling follows
orders, which are exempt).
packages/db/test/storefront-cache.test.ts fails when a new table with a store_id is in
neither list. Rendered by a page: add it to STOREFRONT_CACHE_TABLES and create its trigger in a
migration. Not rendered: add it to STOREFRONT_CACHE_EXEMPT with the reason.
If the listener loses its connection, caching stops (every request renders) until it is back, and the reconnect starts a new epoch, so nothing cached before the gap is served.
Warming
After a change, the store’s recently viewed pages (up to eight, viewed in the last day, noted
by proxy.ts) are re-rendered in the background once the store has been quiet for three seconds
(lib/cache-warm.ts), one at a time, as a visitor without cookies. A merchant who saves and
reloads usually gets the warmed page. During an import nothing is warmed until the writes stop.
Sizes and switches
STOREFRONT_CACHE_MB(default 48): the cache’s memory budget, least recently used entries dropped first. Keep it well under the container’s memory limit.0turns the cache off.STOREFRONT_EAGER_SECTIONS(default 3): how many of an imported theme’s sections are sent first and left as the theme wrote them (see “Imported themes: weight and streaming” above).STOREFRONT_EDGE_TTL(default 30 seconds): see “Shared caches” below.0keeps every page private.
Shared caches (Cloudflare, a CDN)
proxy.ts (lib/edge-cache.ts) sends
Cache-Control: public, max-age=0, s-maxage=30, stale-while-revalidate=300 for a page view that
is the same for every visitor without cookies:
GET/HEADof the home page, products, collections, pages, blogs and articles, policies and search;- no cookie of ours (
wc_*,theme,recently_viewed; analytics cookies do not count); - not a client-side navigation (
RSCheader), not a theme preview, not the password page, not a form result.
Everything else (cart, checkout, account, actions, APIs, any visitor with a cart or an account)
keeps Next’s private, no-cache, no-store. Browsers always revalidate (max-age=0); only a
shared cache keeps the page, for the TTL. A shared cache cannot hear the trigger, so a change
reaches the visitors it serves within the TTL (plus up to the stale window for the first visitor
after it expires). The page carries the CSP nonce it was rendered with; that is fine, because an
attacker who reads a cached nonce would also need markup inside that same cached page, and any
store write replaces it with a fresh render.
Cloudflare in front
The origin is ready for it; switching DNS is a separate, owner-run step.
-
DNS: the store’s records proxied (orange cloud).
-
Origin lock: firewall 80/443 to Cloudflare’s ranges and set
TRUSTED_PROXY=cloudflare(see Deployment). -
SSL/TLS: Full (strict).
-
Cache rule (Caching → Cache Rules → Create rule), named “Store pages”:
-
When (expression editor):
(http.request.method eq "GET" and not http.cookie contains "wc_" and not http.cookie contains "theme=" and not http.cookie contains "recently_viewed=" and not starts_with(http.request.uri.path, "/checkout") and not starts_with(http.request.uri.path, "/cart") and not starts_with(http.request.uri.path, "/account") and not starts_with(http.request.uri.path, "/api/") and not starts_with(http.request.uri.path, "/theme-actions/")) -
Then: Eligible for cache; Edge TTL Use cache-control header if present, bypass cache if not; Browser TTL Respect origin; cache key keeps the query string (filters, sort and page are in it); status code TTL
500-599→ No store.
The cookie conditions repeat the origin’s rule: Cloudflare keys by URL only, so a visitor with a cart must never be answered from it. The origin already marks those pages private; the rule is the second lock.
-
-
Static files:
/_next/static/*and/theme-assets/…?v=already sendimmutablefor a year; Cloudflare caches them by default. -
Purge: not needed for correctness (the TTL is 30 seconds). To show a big theme change everywhere at once, Caching → Configuration → Purge Everything.
Static files
/_next/static/*(JS, CSS, fonts) is content-hashed and served by Next withpublic, max-age=31536000, immutable. The proxy’s matcher skips it.- An imported theme’s
assets/are files, not inlined:/theme-assets/<theme>/<file>?v=<checksum>withpublic, max-age=31536000, immutable. robots.txt,sitemap.xml,llms.txt(public, max-age=3600) and/api/search/suggest(public, max-age=30, stale-while-revalidate=60) are per-host documents; a shared cache keys them by URL, host included. The proxy does not mint the visitor cookie on the SEO files, so noSet-Cookierides on a publicly cacheable response.- Open item: media files in R2/S3 are uploaded without a
Cache-Controlheader, so browsers fall back to heuristic caching. Keys are unique per upload (mediaKeyputs a uuid in each), and variant files are written once, so variants can be servedpublic, max-age=31536000, immutable. The master is rewritten once in place by the pipeline (original, then cleaned master), so it wants a shorter lifetime, such as a day. Set it on upload or with a bucket or CDN rule.
Measuring
Build and start in production mode against a copy of the database, then time the first byte:
curl -s -o /dev/null -w "%{time_starttransfer}\n" -H "Host: <store host>" http://localhost:3100/The first request after a start or a change renders (a miss); the next is a hit. To force a
miss, send the notification by hand: select pg_notify('storefront_cache', '<store id>').
October 2026, Waimers’ imported Zero Drift theme on a laptop, production build:
| Page | Before | Miss (after a change) | Hit |
|---|---|---|---|
| Home (19 Liquid sections) | 500 ms | 400 ms | 25 ms |
| Product | 210 ms | 180 ms | 20 ms |
| Collection | 220 ms | 180 ms | 25 ms |
Database statements on a home page miss went from about 270 to about 160 (products and
collection products load in one batch per render pass, LiquidDataCache in core); a hit for an
anonymous visitor makes none.
Testing on localhost
- Build and start the storefront in production mode (dev mode is not representative):
bun run --cwd apps/storefront build, thenbun run --cwd apps/storefront start. - Open a store host on port 3100 in Chrome, open DevTools, then Lighthouse → Mode Navigation, Device Mobile, Categories Performance → Analyze page load. The mobile preset simulates a mid-range phone (4× CPU slowdown) on slow 4G.
- For real interactions (INP), use the Performance panel with CPU: 4× slowdown and Network: Fast 4G, record, then open the menu, add to cart and change a variant. The Rendering tab’s Layout Shift Regions highlights anything that moves.
- Check the home page, a collection, a product page and checkout. Compare against the
same run on
main; one-off numbers vary by ±10%.