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.
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.
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.
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.
Related Link to this section
- SSR Hydration & Fallback Chains — the parent area and the resolution order this preserves
- Handling SSR Hydration Mismatches in Dark Mode — the related failure inside the render tree
- Negotiating the Theme at the Edge with Middleware — resolving before the origin streams anything at all