Announcements
Performance Best Practices
Monitor the Performance of Storefront Next
SEO URL Rules Best Practices for Storefront Next
Track Storefront Next Activity with Analytics
CLI Reference
B2C Commerce Release Notes
Ask the Community
Use optimization strategies to build fast storefronts. Follow performance best practices for web fonts, resource hints, bundle optimization, and third-party scripts.
This document is the entry point for performance topics. For in-depth documentation, see the topics listed next.
Note
| Topic | Key Areas |
|---|---|
| Data Fetching | Server-load everything, data classification, loaders, actions, fetchers, SCAPI request shape |
| Loading States | Suspense boundary granularity, skeleton vs. spinner, visual feedback patterns |
| State Management | URL state, context selector pattern, optimistic UI, avoiding derived state |
| Images | DIS integration, <DynamicImage>, DynamicImageProvider, responsive sources, alt text |
| Monitor the Performance of Storefront Next | Use performance tools to monitor the performance of Storefront Next and create a performance benchmark. |
To achieve optimal performance during page load, use system fonts or minimize the size of web fonts and improve their discovery. Large web font files take longer to download and negatively affect First Contentful Paint (FCP). An incorrect font-display value can cause layout shifts that contribute to Cumulative Layout Shift (CLS).
Self-host web fonts instead of loading them from third-party CDNs like Google Fonts. Self-hosting eliminates cross-origin DNS lookups and connection setup, avoids browser cache partitioning (browsers isolate third-party CDN caches per site), and is required for GDPR compliance because loading fonts from external CDNs transmits the visitor’s IP address to that third party on every page load. A 2022 ruling by the Munich Regional Court established this as a GDPR violation applicable across the EU. The alternative of gating external font loading behind a consent manager preserves CDN delivery but degrades the experience for users who haven’t consented and adds implementation complexity. For details, see Google Fonts and GDPR.
Browsers use @font-face to find fonts. Help the browser discover fonts earlier by inlining the @font-face declaration in the <head> and adding a <link rel="preload"> directive. Without preload, the browser doesn’t request the font until it computes a style that references it, adding a waterfall delay.
Use the WOFF2 format for its superior compression. Prefer variable fonts because a single file covers multiple weights, reducing the number of requests and preload hints. Subset fonts to include only necessary characters when the full Unicode range isn’t needed.
The font-display CSS property controls how text is shown while a font loads. Use swap to immediately show a system fallback font and swap in the web font once loaded, avoiding Flash of Invisible Text (FOIT). Use optional to eliminate the swap-induced layout shift entirely. The web font is used only if it arrives before first render, otherwise the system font persists.
Using system fonts avoids the font download entirely and eliminates render-blocking. For examples of system fonts, see Fonts for Apple platforms and Windows 11 font list.
For more detail, see Optimize web fonts on web.dev.
Resource hints tell the browser to start DNS lookups, TCP connections, or resource downloads before they’re needed, reducing latency when those resources are eventually requested.
The template renders resource hints in the <head> based on configuration values in config.server.ts, so they can be tuned per environment without code changes (src/root.tsx).
appConfig.links.preconnect: Origins the browser should open early connections to (DNS + TCP + TLS). Use for services that will definitely be contacted on every page, such as the image CDN. The template preconnects to the DIS host by default.appConfig.links.prefetchDns: Origins for DNS-only prefetching. Lighter than preconnect, appropriate for services that may or may not be contacted (for example, analytics, optional third-party APIs).appConfig.links.prefetch: Specific resources to fetch and cache in the background. Use sparingly, as prefetched resources consume bandwidth regardless of whether the user navigates to them.1# Override via environment variables
2PUBLIC__app__links__preconnect='["https://edge.dis.commercecloud.salesforce.com"]'
3PUBLIC__app__links__prefetchDns='["https://analytics.example.com"]'Only preconnect to origins that are actually used on every page. Each preconnect opens a TCP and TLS connection eagerly, so unused preconnects waste the browser’s connection budget and can delay more important requests. Performance audits, such as Lighthouse’s “Avoid unnecessary preconnects”, flag this. If an origin is only used on some pages (for example, a payment provider on checkout), prefer dns-prefetch instead. DNS lookups are cheaper and don’t trigger warnings when unused.
Warning
Vite handles tree-shaking, minification, and chunk splitting automatically. Follow these practices help keep bundles small.
React.lazy() with deferred mounting, split them into separate chunks that are loaded on demand. See Lazy Loading for Overlays for the pattern. For large route-specific component groups, use manualChunks in vite.config.ts to control how Rollup groups modules. The template uses this to split checkout components and per-locale translation files into dedicated chunks that are only loaded when needed.pnpm bundlesize:analyze to generate an interactive visualization of client and server bundles (opens build/client-bundle-size.html and build/ssr-bundle-size.html). Run pnpm bundlesize:test to verify against configured size limits—CI enforces these checks on every PR.Unnecessary re-renders inflate Interaction to Next Paint (INP) and degrade responsiveness. Here are the most impactful optimizations.
useMemo for derivations that are genuinely expensive. Don’t memoize everything because the overhead of memoization exceeds the cost of cheap computations.useCallback to prevent the child from re-rendering on every parent render.React.memo selectively. Wrap components that re-render often with unchanged props. Don’t apply it broadly because it adds comparison overhead and obscures the component tree.Overlay components that are hidden on initial render, such as modals, drawers, and dialogs, must use React.lazy() with deferred mounting. Mount the <Suspense> subtree only after the first user interaction, not on page load. This keeps the overlay’s code out of the main chunk entirely until it’s actually needed, reducing page load size and Total Blocking Time (TBT).
Conditionally render the components based on the overlay’s open state so that the subtree unmounts when the overlay closes. Use useDeferredUnmount(open) to keep them mounted for a short window after close—long enough for the exit animation to play—then unmount. React.lazy memoizes the resolved chunk at module scope, so re-opening after unmount doesn’t re-download the JavaScript. Only the closed overlay’s in-memory state, and any resource fetchers it holds, is released.
1import { useDeferredUnmount } from "@/hooks/use-deferred-unmount";
2const MyModal = lazy(() => import("@/components/my-modal").then((m) => ({ default: m.MyModal })));
3
4function MyComponent() {
5 const [open, setOpen] = useState(false);
6 // Mounted while open, then unmounts shortly after close so the exit animation plays.
7 const mounted = useDeferredUnmount(open);
8
9 return (
10 <>
11 <Button onClick={() => setOpen(true)}>Open</Button>
12 {mounted && (
13 <Suspense fallback={null}>
14 <MyModal open={open} onOpenChange={setOpen} />
15 </Suspense>
16 )}
17 </>
18 );
19}React.lazy caches the chunk so subsequent opens are instant without keeping the subtree alive.open controls whether the overlay should be visible. mounted becomes true immediately when opening, but stays true briefly after open becomes false—long enough for the exit animation to complete before unmounting.Anti-pattern: When you import overlay components synchronously (non-lazy), they’re bundled into the main chunk, increasing page load size and TBT.
Anti-pattern: Don’t use a loaded flag that flips to true on first open and never resets, such as const [loaded, setLoaded] = useState(false) that gates {loaded && <Suspense>…}. This keeps the overlay mounted forever, causing its data fetchers, such as (fetcher.load(), useScapiFetcher), to rerun on every revalidation even after the user closes the overlay. Examples on when revalidations run are when currency, locale, or site change, or when a store is selected. Gate on open with useDeferredUnmount instead.
Discouraged: <Suspense><LazyComponent /></Suspense> without a guard—the chunk is separate but still fetched and parsed on mount, adding to TBT during page startup.
Important
Mounting a <Suspense> boundary at the top of the component tree forces React to retain the entire fallback subtree in memory and process it during initial render, even if the user hasn’t scrolled near that content. For large grids or heavy off-screen sections, this unconditional top-level mounting inflates Total Blocking Time (TBT) and competes for the main thread with LCP candidates in the initial viewport.
The useDeferredRender hook solves this main-thread contention by delaying the mount of a <Suspense> boundary until the browser reports an idle frame via requestIdleCallback. During that idle window, the component renders a lightweight skeleton placeholder instead of a live <Suspense> tree.
Three-Phase Rendering:
| Phase | When | What renders |
|---|---|---|
| Pre-Idle | Immediately after page paint | Critical content and static skeleton placeholders. No <Suspense> boundary mounted. |
| Post-Idle (Pending) | After idle callback fires | <Suspense> boundary mounts with skeleton fallback. Stream begins. |
| Resolved | After Promise settles | Full content replaces skeleton. |
1import { useDeferredRender } from "@/hooks/use-deferred-render";
2
3function MySection({ criticalItems, nonCriticalPromise, placeholderCount }) {
4 const shouldRender = useDeferredRender(placeholderCount > 0);
5
6 return (
7 <>
8 {/* Critical, initial-viewport content always renders synchronously */}
9 <ItemGrid items={criticalItems} />
10
11 {/* Phase 1: no Suspense boundary — minimal render cost */}
12 {!shouldRender ? (
13 <SkeletonGrid count={placeholderCount} />
14 ) : (
15 /* Phase 2 & 3: Suspense boundary mounts after idle */
16 <Suspense fallback={<SkeletonGrid count={placeholderCount} />}>
17 <Await resolve={nonCriticalPromise}>{(items) => <ItemGrid items={items} />}</Await>
18 </Suspense>
19 )}
20 </>
21 );
22}A common source of unnecessary CPU work is data computation that runs on every render when it should happen once in the server loader. Identify and move these transforms before they compound across component trees.
Anti-patterns to avoid:
reduce or nested loops are the most common offender—the work scales with input size and repeats on every render, compounding across every tile in a grid.items.filter(...).map(...).sort(...) or items.map(item => other.find(...)) are O(n²) when repeated per render over larger collections. Derive the shape you need once, and index once.items.map(item => ({ ...item, children: item.children.map(c => ({ ...c })) })) allocates a fresh object tree every render and breaks prop equality for every child downstream.1// ❌ BAD: lookup map rebuilt, and nested find/sort repeated on every render
2function Grid({ items, categories }) {
3 const byCategory = items.reduce((acc, item) => {
4 (acc[item.categoryId] ??= []).push(item);
5 return acc;
6 }, {});
7 const rows = categories
8 .map((c) => ({ ...c, items: byCategory[c.id] ?? [] }))
9 .sort((a, b) => a.label.localeCompare(b.label));
10 return (
11 <>
12 {rows.map((row) => (
13 <Row key={row.id} row={row} />
14 ))}
15 </>
16 );
17}
18
19// ✅ GOOD: derive once in the loader; component just renders
20export async function loader({ params }) {
21 const [items, categories] = await Promise.all([fetchItems(params.id), fetchCategories()]);
22 const rows = buildRows(items, categories); // group + sort once, server-side
23 return { rows };
24}
25
26function Grid() {
27 const { rows } = useLoaderData();
28 return (
29 <>
30 {rows.map((row) => (
31 <Row key={row.id} row={row} />
32 ))}
33 </>
34 );
35}When displaying promotional pricing on a product listing page (PLP) or search results page, you have two options: use expand=promotions on SCAPI search calls, or pre-compute promotional prices into a custom sortable attribute via a scheduled job. The right choice depends on whether you sort results by promotional price.
Key point: expand=promotions calculates promotional prices at response time, after search results have already been sorted and returned. It cannot be used to sort results by promotional price.
To show promotional pricing information (promotional price, callout message, promotion ID) on your PLP alongside search results, use expand=promotions. It’s straightforward and requires no additional setup, but it only enriches results — it doesn’t influence their order.
On high-traffic PLPs, using expand=promotions adds per-request calculation overhead. If showing promotion details (such as callout messages) is the only requirement, this trade-off is generally acceptable.
To sort search results by promotional price on high-traffic PLPs, use a custom sortable attribute populated by a scheduled job. This approach pre-computes promotional prices ahead of time so the search engine can sort by them natively with no per-request cost.
Combine both approaches if you sort by promotional price on a PLP and also want to show promotion details such as callout messages in the results. Use the custom sortable attribute to drive sort order, and expand=promotions to enrich the returned results with display data.
Third-party scripts, such as analytics, tag managers, A/B testing, chat widgets, and consent banners, are a common source of performance degradation. Each script adds to Total Blocking Time (TBT) and can delay Interaction to Next Paint (INP).
Keep these tips in mind for best performance results.
async or defer. A synchronous <script> blocks HTML parsing entirely.