The Dark Mode Flash: Small Bug, Big Rage
You’re trying to debug the dark mode flash: your user picked dark theme, but every page load blasts them with a white screen for a couple hundred milliseconds before the theme kicks in. It’s a FOUC — flash of unstyled (or here, mis-themed) content — and the mechanism is always the same: the theme decision runs after the first paint. The browser paints your default theme, then your JavaScript wakes up, reads the preference, and repaints. Everything between those two moments is the flash.
It sounds trivial. It is not treated as trivial by users. Dark mode people are dark mode people at 1 a.m., and a full-brightness white flash on every navigation is the kind of bug that shows up in cancellation surveys phrased with real hostility. Small bug, big rage.
Debugging the dark mode flash (FOUC): the mechanism
Walk through what the browser actually does. It receives your HTML and starts rendering with whatever styles apply — your CSS defaults, almost always the light theme. Meanwhile the stored preference lives in localStorage, which only JavaScript can read. If that read happens in a React useEffect, you’ve lost by a mile: effects run after the component renders and after the browser paints. Even a top-of-<body> script is often too late if styles have already committed a first frame.
So the fix isn’t “run the theme code earlier in React.” React never gets a chance to be early enough. The fix is running the decision before the first paint, which means a render-blocking inline script in <head>:
<head>
<script>
(function () {
try {
var theme = localStorage.getItem('theme');
if (!theme) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
}
document.documentElement.dataset.theme = theme;
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="/styles.css">
</head>
Inline scripts in <head> block parsing, which is normally the thing performance guides yell at you about — here it’s precisely the point. The attribute lands on <html> before any pixel is painted, your CSS keys off [data-theme="dark"], and there is no frame in which the wrong theme exists. The try/catch is one of the few I’ll defend: localStorage access throws in some private-browsing configurations, and a theme script that takes down the page would be an all-time self-own.
Two details people miss. Put the script before the stylesheet link, so the attribute is set before styles are ready to apply. And default to prefers-color-scheme when there’s no stored choice — a first-time visitor with a dark OS should get dark without ever touching your toggle. Also set color-scheme: light dark in your CSS so scrollbars and form controls follow along; nothing undermines a dark theme like a glowing white scrollbar.
SSR frameworks: localStorage vs. cookies
If you’re on Next.js or a similar framework, there’s a fork in the road, because the server composes your HTML and the server cannot read localStorage.
Option one: keep localStorage, ship the inline script. The server renders theme-agnostic HTML, the head script applies the attribute pre-paint. This works, and it’s what most theme libraries (like next-themes) do under the hood. The catch is hydration: the server didn’t know the theme, the client does, so if the theme affects anything React renders — a toggle’s icon, say — the two disagree. That’s why these libraries lean on suppressHydrationWarning on <html>, and why the dark mode flash is really a member of the same species as every Next.js hydration mismatch: state the client knows and the server doesn’t, resolved at the wrong time. The inline script resolves it before paint, which is the only slot where nobody sees the disagreement.
Option two: store the theme in a cookie. Cookies ride along on the request, so the server reads the preference and renders <html data-theme="dark"> directly. No script, no mismatch, no flash — the honest, boring solution, and my preference for any app that’s already SSR. Costs: a sliver of plumbing (set the cookie when the toggle changes, read it in your layout), and cached-at-the-edge pages get more complicated because the HTML now varies per user. For a static site behind a CDN, that trade usually tips back toward the inline script.
What doesn’t work, for the record: hiding the page until the theme loads (you’ve traded a flash for a blank stare), CSS-only prefers-color-scheme with no override (fine until the day someone asks for a toggle), and useEffect with a fade transition to make the flash “smooth” (a slower flash is still a flash, now with extra apology).
Proving you actually fixed it
The flash is timing-dependent, so “looks fine on my machine” means nothing — your dev machine has a warm cache and a fast CPU. Verify under hostile conditions: DevTools, CPU throttling at 6x, network on Slow 3G, hard reload with cache disabled, OS in dark mode with the stored preference set to dark. If the first painted frame is dark, you’re done. Chrome’s Performance panel makes this rigorous — record a page load with screenshots enabled and scrub through the filmstrip; the wrong-theme frame is either there or it isn’t. Test navigations too, not just cold loads, and test the logged-out page your CDN caches — that’s where option two’s caching caveat comes home to roost.
Then leave a tripwire. A tiny check after load — does the applied theme attribute match the stored preference, and did it match at first paint? — logged to your analytics or error tracker, tells you if a refactor reintroduces the flash. This bug has a documented tendency to come back the first time someone reorganizes the <head> and moves your weird little blocking script, because six months from now it will look like a mistake to someone. Comment it accordingly. Mine says: “This blocks rendering on purpose. Move it below the stylesheet and the dark mode flash returns. Ask me how I know.”
See the bug the way your user did
LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.
Try LogReplay free