Skip to Content
DevelopersBuildingStorefront performance

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:

MetricTargetWhat moves it here
LCP (Largest Contentful Paint)under 2.5 sthe hero or first product image, fonts, render-blocking JS
INP (Interaction to Next Paint)under 200 mshow much JS hydrates and runs on the main thread
CLS (Cumulative Layout Shift)under 0.1images 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 diffs

It 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:

  1. Keep heavy libraries out of client modules’ static imports. A 'use client' file that imports motion, zod or a large SDK puts that library on every storefront page.
  2. 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:

NeedImport fromNot 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' or import { 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 useReducedMotion from packages/blocks/src/use-reduced-motion.ts (a useSyncExternalStore over the media query). It answers false during 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 after load, 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 }. renderTemplate collects every image URL in a template’s section props, looks them up in media in one query under the store’s RLS (apps/storefront/src/lib/media-lookup.ts, pure part in section-images.ts), and adds variants and thumbhash before the sections render. URLs the library does not know (external links, legacy uploads) render as before.
  • Product images select variants and thumbhash in getProduct and listCardProducts. Gallery thumbnails and predictive search results use the 320 px WebP variant, not the original.
  • fill is for images sized by their container (width/height: 100%, absolute inset-0, CSS aspect-ratio). It gives the <picture> wrapper display: contents, so percentages resolve exactly as they did against a bare <img>. Inline, because the blocks stylesheet’s unlayered picture { display: block } beats utility classes.
  • placeholder={false} for object-fit: contain images (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. renderTemplate passes _aboveFold: true to that section, and hero-type blocks (image banner, split hero) honour it. Everything else is loading="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 a height from 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

ScriptWhen it loads
GA4, Tag Manager, Meta pixelnext/script lazyOnload: after load, when the browser is idle, and only with consent
Razorpay Affordability WidgetafterLoadWhenIdle (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 RSC header from headers() and from the proxy, so flightOnlyRequest (lib/flight-request.ts) tells them apart by Next-Action and Sec-Fetch-Dest (the router uses fetch, so empty).
  • 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; LiquidSlot then 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):

WhatChange
<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 scriptsdata-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

CachedKey (besides the store and its version)Code
An imported theme’s rendered sectionstheme id + files version, origin, path, template, posted form, a digest of the section’s settingslib/liquid-theme.ts, lib/liquid-cache.ts
An imported theme’s layout (head, icon sprites, cart drawer)the same, without the sectionliquidLayoutFor
Theme data Liquid reads (products, collections, menus, pages, blogs, extra fields, content entries)origin + the read and its argumentscachedLiquidData
Templates, loops, connected sources, section images, the app block listtemplate key and suffix, a digest of the bound pathslib/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 settingshandle or product idproducts/[handle]/page.tsx, lib/reviews.ts
Collection and search resultshandle (or terms), filters, sort and pagecollections/[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.

WriteEffect
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 settingsthat 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 colournothing
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. 0 turns 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. 0 keeps 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/HEAD of 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 (RSC header), 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.

  1. DNS: the store’s records proxied (orange cloud).

  2. Origin lock: firewall 80/443 to Cloudflare’s ranges  and set TRUSTED_PROXY=cloudflare (see Deployment).

  3. SSL/TLS: Full (strict).

  4. 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.

  5. Static files: /_next/static/* and /theme-assets/…?v= already send immutable for a year; Cloudflare caches them by default.

  6. 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 with public, 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> with public, 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 no Set-Cookie rides on a publicly cacheable response.
  • Open item: media files in R2/S3 are uploaded without a Cache-Control header, so browsers fall back to heuristic caching. Keys are unique per upload (mediaKey puts a uuid in each), and variant files are written once, so variants can be served public, 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:

PageBeforeMiss (after a change)Hit
Home (19 Liquid sections)500 ms400 ms25 ms
Product210 ms180 ms20 ms
Collection220 ms180 ms25 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

  1. Build and start the storefront in production mode (dev mode is not representative): bun run --cwd apps/storefront build, then bun run --cwd apps/storefront start.
  2. 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.
  3. 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.
  4. Check the home page, a collection, a product page and checkout. Compare against the same run on main; one-off numbers vary by ±10%.
Was this helpful?