Storefront Next Performance
Performance rules are part of AGENTS.md ("Performance & Data Rules") and detailed in docs/README-PERFORMANCE.md, docs/README-SUSPENSE.md, docs/README-IMAGES.md and docs/README-PERFORMANCE-METRICS.md in your project. This skill is the working summary plus a checklist to run on your own diff: PERFORMANCE-REVIEW-CHECKLIST.md.
Core rules
- Server-load data. Await only what SEO, LCP, layout or the HTTP status needs; return promises for the rest (see
storefront-next:sfnext-data-fetching). - One
<Suspense>per async operation, with a skeleton that reserves the final size. Nofallback={null}above the fold unless space is reserved. - Promise identity must be stable across renders: compose in the loader, read from
loaderData/props, neverPromise.all/.then/new Promisein render, neveruseMemoaround a promise. Escape hatches:useState(() => ...)pinning oruseRefre-pinning. - Every
use()/<Await>consumer needs a<Suspense>ancestor; otherwise it blocks the first byte of the streamed shell. - Heavy or hidden UI (modals, drawers, dialogs, rich editors) is
React.lazyand mounted only while needed. - Shape data in the loader, not in render.
- Hints and third-party scripts cost: preconnect only to origins used on every page; load scripts
async/defer.
Lazy modals and drawers
import { lazy, Suspense, useState } from 'react';
import { useDeferredUnmount } from '@/hooks/use-deferred-unmount';
const SizeGuide = lazy(() => import('@/components/size-guide').then((m) => ({ default: m.SizeGuide })));
export function SizeGuideButton() {
const [open, setOpen] = useState(false);
const mounted = useDeferredUnmount(open); // stays mounted briefly after close for the exit animation
return (
<>
<button onClick={() => setOpen(true)}>Size guide</button>
{mounted && (
<Suspense fallback={null}>
<SizeGuide open={open} onOpenChange={setOpen} />
</Suspense>
)}
</>
);
}
Avoid a sticky "loaded" latch (a state that flips true on first open and never resets): fetchers inside the modal stay registered and re-run on every later revalidation. See src/components/product-tile/quick-add-button.tsx.
Deferred rendering
useDeferredRender(enabled, options) (@/hooks/use-deferred-render) delays mounting a Suspense boundary until an idle frame; use it for large below-the-fold grids. DeferredProductGrid (@/components/product-grid/deferred) is the reference. useDeferredRenderSequence(n) fans out work one step per idle frame.
Page Designer
Mark a region <Region critical> only when it is required for the initial HTML or is an LCP candidate, and await that page in the loader (omit a region fallback). Below-the-fold regions stay streamed. See storefront-next:sfnext-page-designer.
Images, fonts, hints
- Images: use
DynamicImage(@/components/dynamic-image). Props:src,alt,widths,heights,imageProps,as,className,loading,priority,objectFit. Give the LCP imagepriority="high"(React 19 preload) and never lazy-load it. Providewidthsmatching the layout, or wrap a group inDynamicImageProvider(@/providers/dynamic-image,value={{ widths }}). URL helpers are indocs/README-IMAGES.md. - Fonts: the template self-hosts
public/fonts/sen-variable.woff2, preloads it insrc/root.tsxlinks, and declares@font-facewithfont-display: swapinsrc/theme/base.css. Keep fonts same-origin, preload only the one above-the-fold face, and consider system fonts for secondary text. - Resource hints: configure
app.links.preconnect,prefetchDns,prefetchinconfig.server.tsor viaPUBLIC__app__links__preconnect='["https://cdn.example.com"]'. Default preconnects to the image host only.
Measure
| Task | Command |
|---|---|
| Check bundle size limits (block CI) | pnpm bundlesize |
| Interactive treemaps (build/client-bundle-size.html, build/ssr-bundle-size.html) | cross-env BUNDLES_SIZE_ANALYZE=true pnpm build |
| Compare size runs | pnpm bundlesize:compare |
| Lighthouse | pnpm lighthouse:ci |
| Request timings | performance.metrics flags in config.server.ts |
The limits live in the bundlesize block of package.json. If a legitimate change exceeds a limit, state the reason and raise it deliberately; do not use manualChunks to bucket components.
performance.metrics (serverPerformanceMetricsEnabled, serverTimingHeaderEnabled, clientPerformanceMetricsEnabled) are all false in the shipped config.server.ts; docs/README-PERFORMANCE-METRICS.md lists two of them as defaulting to true, but the shipped config wins. Enable them only while debugging, because serverTimingHeaderEnabled adds a Server-Timing header and costs response time. Override via PUBLIC__app__... environment variables (storefront-next:sfnext-configuration).
Review workflow
- Run the checklist on your diff (fetch topology, Suspense, promise stability, hydration, revalidation scope, client transforms).
pnpm typecheck,pnpm lint,pnpm test.pnpm bundlesizewhen you add dependencies or routes.- Load the page with network throttling and CPU slowdown; confirm no layout shift when streamed sections resolve and that server HTML contains the LCP element.
Impact rule of thumb: traffic of the page x number of instances on it x cost per instance. A fetch in a product tile is multiplied by every tile.
References
- PERFORMANCE-REVIEW-CHECKLIST.md - self-review checklist with rules, bad/good forms and exceptions
- SUSPENSE-AND-STREAMING.md - placement, granularity, promise stability recipes
Finding more
AGENTS.md "Key Documentation" and docs/README-PERFORMANCE.md. b2c docs search "storefront next performance" or docs_search MCP tool.
Related Skills
storefront-next:sfnext-data-fetching- loaders, streamingstorefront-next:sfnext-revalidation- cutting wasted loader re-runsstorefront-next:sfnext-state-management- render scope and storesstorefront-next:sfnext-components- component conventionsstorefront-next:sfnext-page-designer- critical regionsstorefront-next:sfnext-quality-gates- lint, typecheck, tests before mergestorefront-next:sfnext-deployment- bundle and MRT limitsb2c-cli:b2c-mrt- Managed Runtime logs and deploys