How would you persist the user’s theme across sessions and devices
Persist locally in localStorage for instant application (read before first paint to avoid FOUC), and sync to the user's server profile so it follows them across devices. Respect prefers-color-scheme as the default, and apply via a data-attribute/class on the root.
Theme persistence has two scopes — this device (fast, local) and this user (cross-device, server) — and a critical constraint: no flash of the wrong theme on load.
Layered persistence
- localStorage — the source of truth for this browser. Instant, synchronous, survives restarts.
- Server (user profile) — the source of truth for this user across devices. On login, fetch the saved theme; on change,
PATCHit to the profile. prefers-color-scheme— the default when the user has no explicit preference. Three states:light,dark,system(follow the OS).
Priority on load: explicit user choice (server, if logged in) → localStorage → system / prefers-color-scheme.
Avoiding FOUC (the part interviewers probe)
If React applies the theme after hydration, the user sees a white flash then dark. Fix: apply the theme before first paint with a tiny blocking inline script in <head>:
<script>
// runs before paint, before the app bundle
const t = localStorage.getItem('theme')
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = t;
</script>For SSR, this is exactly why next-themes exists, and why frameworks recommend a small head script. With server-stored themes, you can also SSR the correct data-theme directly if you know the user.
Applying the theme
- Set
data-theme="dark"(or a class) on<html>; CSS keys off it via custom properties:
``css :root { --bg: white; } [data-theme="dark"] { --bg: #111; } ``
- One attribute flip re-themes the whole app — no re-render storm, no per-component logic.
Cross-device sync
- On login: fetch profile theme, apply, mirror into localStorage.
- On change: update localStorage immediately (instant),
PATCHthe server in the background. - Conflict: server wins for a logged-in user; reconcile gently.
Extra polish
- Cross-tab sync —
storageevent so changing the theme in one tab updates the others. systemmode — keep amatchMedialistener so the app follows the OS if it changes at runtime.color-schemeCSS property so native form controls/scrollbars match.
Follow-up questions
- •How do you prevent a flash of the wrong theme on initial load?
- •How does theme persistence work with SSR?
- •How do you keep 'system' mode reactive if the OS theme changes?
- •Server vs localStorage — which wins, and when?
Common mistakes
- •Applying the theme after hydration, causing a flash of the wrong theme.
- •Only localStorage, so the theme doesn't follow the user to another device.
- •Forgetting the 'system' option and the prefers-color-scheme default.
- •Theming via JS per component instead of one root attribute + CSS variables.
Performance considerations
- •A blocking head script is tiny and runs once before paint — acceptable cost to kill FOUC. Theming via a root attribute + CSS custom properties means zero JS re-renders on theme change. The server PATCH is fire-and-forget; never block the UI on it.
Edge cases
- •SSR sending a theme that mismatches the client (hydration mismatch).
- •User logged out vs logged in — different source of truth.
- •OS theme changing while the app is open in 'system' mode.
- •localStorage disabled by privacy settings.
Real-world examples
- •next-themes handling localStorage + system + no-flash for Next.js apps.
- •GitHub/Linear syncing theme to the account so it's consistent across devices.