Skip to main content

Section Performance Rules

These rules apply to every section, page, and shared component. They are not style preferences. Each rule prevents a specific engine behaviour that costs layout stability or main-thread time.

Read Performance Overview first for the three problems these rules solve.

Rule 1: The server HTML must equal the first client render

The engine streams server HTML and then hydrates it. If the two disagree, React either reports a hydration mismatch or re-renders the subtree without a message. In both cases the layout can jump.

At render time, never branch on a value that exists only in the browser:

  • window, document, navigator, localStorage, matchMedia, window.innerWidth
  • Date.now(), new Date(), Math.random()
  • A mounted, isClient, or isRunningOnClient() gate that returns different markup
  • The result of a request that is still in flight

All of these are correct after mount, in an effect, an event handler, or an idle callback. The rule applies only to the first render.

Device detection

Every theme needs to know whether the device is mobile at render time. Do not read window.innerWidth.

Read the device class from the request User-Agent on the server, pass it to the client, and let the first client render agree with the server. Switch to live matchMedia only after mount. Expose one shared useIsMobile() hook and use it everywhere.

Prefer a CSS media query wherever both layouts can live in one markup tree. CSS never causes a hydration mismatch.

Rule 2: Reserve space for all media

Every image, video poster, and advertisement slot must sit in a box whose height is known before the bytes arrive. An element with height: auto reserves no height, then grows and pushes the page down. This is the most common cause of layout shift.

Derive the ratio from server-stable data only, in this order:

  1. Intrinsic media dimensions from the server payload. These are exact.
  2. A ratio configured on the section, which is a schema default and always present.
  3. A safe default, such as 16 / 9 for a banner.
const ratio =
(imgW > 0 && imgH > 0 ? `${imgW} / ${imgH}` : null) // Intrinsic and exact
|| parseAspectRatio(props?.aspectRatio?.value) // Configured fallback
|| "16 / 9"; // Safety net

<div style={{ width: "100%", aspectRatio: ratio, overflow: "hidden" }}>
<img
src={url}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
decoding="async"
/>
</div>;

Two cases differ on purpose:

  • Content images, such as banners and gallery tiles, put the intrinsic dimensions first. The intrinsic values match the uploaded image, so the box never settles a second time when the merchant leaves the configured ratio at a default that does not match.
  • Advertisement slots put the configured ratio first, and use it only. The creative arrives from an external server, so intrinsic dimensions are unknown during server rendering.

Do not hide the image until an effect makes it visible. That separates the paint from the download and delays the hero paint. Let the server-rendered image paint when it downloads, and put any shimmer behind it.

Rule 3: Use a three-state matched skeleton for client-fetched content

A section that fetches or derives its content in the browser is the largest source of layout shift, because the content arrives after the first paint.

Render the section through three states that all occupy the same height, H:

StateWhenHeight
Reserved empty placeholderServer renderH
Loading skeletonClient mount, while the request runsH
Real contentWhen the data arrivesH

If H is the same in all three states, no transition re-sizes the section. The layout shift is then zero on every run, not only on the runs where the API answered quickly.

The skeleton must be a faithful placeholder of the final markup: the same card count, the same grid columns, and the same card height. A generic spinner still changes size when it is replaced, and it still shifts the page. The swap must change pixels, never dimensions.

Make the height predictable so that the skeleton can match it:

  • Images: an explicit width and height, or an aspect-ratio box from Rule 2.
  • Text: clamp the title and the body to a fixed number of lines. A short title and a long title must occupy the same box.
  • Meta rows: a fixed height.

Then H = ceil(cardCount / columns) x (cardHeight + rowGap), and the same H feeds the reserve, the skeleton, and the real content.

Content of unknown length cannot be matched. Bound it instead. Paginate it, fix the count, or append extra items below the visible area.

Rule 4: Schedule upgrades at requestIdleCallback

Any work after hydration must run at requestIdleCallback, not in a plain mount effect. This includes an interactive widget that replaces a static placeholder, a heavy library, and content that the client fetched. A setState call inside the hydration window competes with hydration for the main thread and can move the layout.

useEffect(() => {
let cancelled = false;
const id = (window.requestIdleCallback || ((cb) => setTimeout(cb, 300)))(
async () => {
const mod = await import("heavy-lib");
if (!cancelled) setLib(mod); // Only after the import resolves
}
);
return () => {
cancelled = true;
(window.cancelIdleCallback || clearTimeout)(id);
};
}, []);

Always schedule at idle, wait for the result, set the state, and cancel on unmount. Keep the cancelled flag so that a late result cannot set the state of a component that is gone.

Rule 5: Animate with CSS, never with a JavaScript timer

Use @keyframes and transition. Never drive an animation with a timer that calls setState.

A timer that sets state re-renders the component on every tick for the whole life of the page. A 10 ms interval runs about 100 renders each second, and every one of them costs main-thread time.

Drive progress bars, autoplay timers, and reveal effects from CSS. Write a timing value into a CSS custom property and let the browser animate it.

Rule 6: Import shared libraries deep

import Foo from "@lib/components/Foo"; // Correct. Pulls only Foo.
import { Foo } from "@lib"; // Wrong. Pulls the whole library.

The theme ships as one bundle, and the section builder does not split chunks. A barrel import therefore pulls the whole library into the synchronous bundle. The browser parses and runs every byte of that bundle before the page becomes interactive.

Rule 7: Load heavy libraries at idle, behind identical static markup

A carousel engine, a chart library, a video player, or a map must not be a static import inside a section.

  1. Remove the static import of the JavaScript of the library. Keep its CSS import static, because the static markup depends on it.
  2. Render static markup that reproduces the markup the library creates before it starts. Use the same wrapper class names, identifiers, and ARIA attributes.
  3. Load the library at requestIdleCallback, as in Rule 4.
  4. Skip the library when the section does not need it. A carousel with one slide needs no carousel engine.
  5. Guard every reference to the library, because the code runs before the library arrives.

The static markup must equal what the library produces. The first paint then agrees with the server, and nothing moves when the library takes over.

Rule 8: Serve large decorative SVG files as asset URLs

An SVG loader puts every imported SVG into JavaScript, which makes the synchronous bundle larger.

Serve a decorative or illustrative SVG as a URL, through <img src="...svg"> or a CSS background. Keep an inline SVG component only for an icon that must change colour or animate from a property. A logo, a hero illustration, and a payment-glyph strip are all asset URLs.

Rule 9: Use font-display optional with a metric-matched fallback

A web font causes a reflow when the browser replaces the fallback font with a web font of different metrics.

  • Load the display font with font-display: optional, not swap.
  • Preload the woff2 file.
  • Add a fallback @font-face that uses size-adjust, ascent-override, and descent-override so that the fallback occupies the same box as the web font.
@font-face {
font-family: "Brand";
src: url("/fonts/brand.woff2") format("woff2");
font-display: optional;
}

@font-face {
font-family: "Brand-fallback";
src: local("Arial");
size-adjust: 97%; /* Tune until the fallback matches the web-font metrics */
ascent-override: 95%;
descent-override: 22%;
}

body {
font-family: "Brand", "Brand-fallback", system-ui, sans-serif;
}

The swap then changes the glyphs, and it does not change the layout.


Was this section helpful?