Streaming SSR and Theme Injection Order

Part of SSR Hydration & Fallback Chains. Streaming changes when each part of the document reaches the browser, and a theme resolver that was correct in a buffered render can end up running after content has already painted.

Byte order in a streamed response A streamed response timeline showing the head shell flushed first, then a first content chunk, then later chunks resolving Suspense boundaries, with the theme resolver placed in the head shell before any content. head shell CSS + theme resolver flushed immediately first content shell markup first paint happens here suspended chunk arrives later inserted by a script hydrate last The resolver has to be in the first chunk Anything after the first content chunk runs after something has already painted, which is the flash.
Streaming makes the head shell a hard boundary: the theme must be decided inside it, because content begins painting as soon as the next chunk arrives.

Prerequisites Link to this section

  • A streaming server renderer — React renderToPipeableStream, a framework built on it, or an equivalent in another ecosystem.
  • The theme resolution model from the parent area: cookie first, stored value second, OS preference third.
  • Access to the document shell template, which is where the resolver has to live.
  • A way to inspect the raw response stream, since this is a byte-order problem and only the bytes settle it.

Step-by-Step Implementation Link to this section

Step 1 — Put the resolver in the shell, not in a component Link to this section

Intent: the resolver must be part of the first flushed chunk, which means it belongs in the document template rather than in the React tree.

// server/shell.js — emitted before renderToPipeableStream begins writing the body.
export const headShell = (theme) => `<!DOCTYPE html>
<html lang="en"${theme ? ` data-theme="${theme}"` : ''}>
<head>
<meta charset="utf-8">
<script>
(function(){var r=document.documentElement;if(r.getAttribute('data-theme'))return;
try{var s=localStorage.getItem('theme');r.setAttribute('data-theme',
s==='light'||s==='dark'?s:(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'));}
catch(e){r.setAttribute('data-theme','light');}})();
</script>
<link rel="stylesheet" href="/assets/main.css">
</head>
<body><div id="root">`;

Why this works: the shell string is written to the response before the renderer starts, so it is in the first packet regardless of how long any component takes to render. The early return means the script does nothing when the server already resolved the theme from a cookie — the two mechanisms compose rather than compete, exactly as in the non-streaming case.

Step 2 — Keep the resolver out of the React tree entirely Link to this section

Intent: a component that renders a script tag is subject to the streaming order and to hydration.

// Wrong: this script is part of the tree and may flush after content.
function ThemeScript() {
  return <script dangerouslySetInnerHTML={{ __html: resolverSource }} />;
}

// Right: the shell owns it, the tree never sees it.

Why this works: React may reorder or defer script elements it renders, and a script inside a Suspense boundary will not run until that boundary resolves. More subtly, a script rendered by the tree participates in hydration, and React will warn about the mismatch between the server-rendered script and the client’s expectation. The shell is outside all of that.

Step 3 — Set color-scheme in the shell too Link to this section

Intent: the browser’s own surfaces — scrollbars, form controls, the canvas behind the page — need the hint before first paint.

<html lang="en" data-theme="dark" style="color-scheme: dark">

Why this works: color-scheme on the root element tells the browser to paint its own UI in the matching mode. Setting it via a stylesheet works too, but the stylesheet may arrive fractionally later than the first content chunk, producing a brief white canvas behind a dark page — a flash that is small, real, and eliminated entirely by putting it in the shell attribute.

Step 4 — Handle boundaries that render theme-dependent markup Link to this section

Intent: a suspended component that branches on the theme reintroduces the mismatch problem inside a later chunk.

// Wrong: this component's output depends on the theme at render time.
function Chart({ data }) {
  const theme = useTheme();
  return <Canvas palette={theme === 'dark' ? darkPalette : lightPalette} data={data} />;
}

// Right: render one tree, read the resolved value on the client after mount.
function Chart({ data }) {
  const palette = useResolvedPalette();   // reads computed custom properties on mount
  return <Canvas palette={palette} data={data} />;
}

Why this works: the server has no reliable theme for a suspended chunk — it may not have had the cookie, and it certainly does not have the OS preference. Rendering one tree and reading the resolved values on the client keeps the markup identical in every case, which is the same principle that resolves hydration mismatches in the non-streaming case.

Where a theme decision may and may not live Three placements compared: the head shell which is always safe, a component in the shell markup which is usually safe, and a suspended boundary which is never safe for theme-dependent markup. Head shell first bytes, always before any paint safe: the resolver belongs here Shell markup rendered synchronously no Suspense above it safe only if the server had a cookie Suspended boundary arrives after first paint inserted by a script never branch on theme in markup here
The rule is the same as in a buffered render, made sharper: no markup anywhere should differ by theme, and the resolver must be in the first bytes.

Step 5 — Watch out for early flush hints Link to this section

Intent: an early hints response or a preload flush can start the stylesheet download before the shell, which is good — and can also reorder what you expect.

res.writeEarlyHints({
  link: ['</assets/main.css>; rel=preload; as=style'],
});

Why this works, with a caveat: early hints get the stylesheet in flight sooner, which reduces the window between the shell arriving and styles applying. It does not change where the theme decision happens, and it must not be used to move the theme script earlier — a preloaded script still executes in document order, so an early hint for it buys nothing and adds a request.

Step 6 — Verify by reading the stream, not the DOM Link to this section

Intent: the DOM after load looks the same either way; only the byte order distinguishes correct from lucky.

# The theme script must appear before the first content chunk.
curl -sN https://example.com/ | head -c 2000

Look for three things in order: the data-theme attribute or the resolver script, the stylesheet link, and only then the first <div id="root"> content. If the resolver appears after any rendered markup, it is running after that markup could have painted — even if, on a fast connection, the difference is invisible.

Verification Link to this section

Throttle the connection and disable the cache, then record a filmstrip of the load. What you are looking for is a single frame transition from blank to themed, with no intermediate frame showing light content. On a slow connection with a large document, an incorrectly placed resolver produces one or more frames of light content before the swap, and those frames are exactly what a visitor on a slow network sees.

Then repeat with a cookie present and confirm the server-rendered attribute is in the first bytes, and with no cookie and a dark OS preference, confirming the client script fills the gap. Both should produce the same filmstrip.

Filmstrip comparison of correct and incorrect resolver placement Two filmstrips of four frames each: the correct placement goes blank to themed with no intermediate state, while the incorrect placement shows two frames of light content before switching. resolver in the head shell blank themed shell + content complete resolver after the first content chunk blank light content still light swaps to dark
The two middle frames are the whole defect. They are invisible on a fast local connection and clearly visible on a throttled one, which is why the filmstrip is the right instrument.

Troubleshooting Link to this section

Symptom Likely cause Fix
A flash appears only on slow connections The resolver is after the first content chunk Move it into the head shell string
The resolver runs twice It is in both the shell and the React tree Remove the tree copy; the shell owns it
Suspended content flashes light before matching A component branches on the theme in markup Render one tree; read resolved values after mount
Scrollbars are light behind a dark page color-scheme is set by CSS rather than in the shell Set it as an inline attribute on the root element
The attribute is present but styles apply late The stylesheet is not preloaded Add an early hint or a preload link for the stylesheet

Migration Note Link to this section

Moving from a buffered to a streaming renderer is where this defect usually appears, and it appears without any theme code having changed — which makes it hard to attribute. The tell is that the flash is new, only visible under throttling, and correlates with the streaming rollout rather than with any theme work.

The fix is nearly always to move the resolver from wherever the framework placed it into the shell template. Frameworks that generate the document for you may not expose that template directly; where they do not, look for the documented hook for injecting content into the head before the stream begins, which most of them have precisely for this class of problem.

Why Streaming Makes This Sharper Rather Than Different Link to this section

The underlying rule has not changed: the theme must be decided before anything paints. What streaming changes is how much slack there is between those two events.

In a buffered render, the entire document is assembled on the server and sent as one response. The browser receives head and body together, so a resolver anywhere in the head runs before any content can paint — even a resolver placed carelessly, after several kilobytes of other head content, still runs in time. The margin is generous enough that mistakes do not show.

Streaming removes the margin deliberately, because removing it is the point: the browser is meant to start painting the shell as soon as it arrives rather than waiting for the slowest part of the page. That means the gap between “head received” and “content painted” shrinks from hundreds of milliseconds to a few, and any resolver that was relying on the slack now loses the race.

The practical consequence for a team adopting streaming is that theme handling should be reviewed as part of the migration, alongside the more commonly discussed concerns about data fetching and Suspense boundaries. It is a small review — check that the resolver is in the shell string — and it is much cheaper than diagnosing an intermittent flash weeks later.

There is a broader lesson worth stating. Streaming exposes a class of ordering assumption that buffered rendering hides, and theme resolution is only the most visible instance. Anything that has to happen before first paint — a locale decision, a feature flag that affects layout, a critical CSS inline block — has the same requirement and the same failure mode.