Server-Side Rendering and Worker Hydration Pitfalls
ReferenceError: Worker is not defined is the friendly version of this problem. The unfriendly versions are a hydration mismatch that silently discards server markup, and a thread started 40 ms before hydration that makes the page slower than the version with no worker at all.
This guide covers the server-rendering constraints on Framework Integration Patterns, under Web Workers Architecture & Communication. Everything here comes from one fact: the component module is evaluated twice, in two runtimes, and only one of them has threads.
Why the Constructor Fails on the Server
Server rendering evaluates your component modules in Node (or Deno, Bun, or a Workers-style edge runtime). None of those define the DOM Worker constructor as a global. Node has node:worker_threads, which is a different API with a different object model; edge runtimes typically have neither.
Anything at module scope runs during that evaluation:
// ✗ Runs during server rendering — throws before a single component renders.
const worker = new Worker(new URL('./stats.worker.ts', import.meta.url), { type: 'module' });
export function useStats() { /* … */ }
The failure is immediate and loud, which makes it the easy case. Two harder variants:
// ✗ Also module scope, just less obvious — the call runs at import time.
export const api = createWorkerApi();
// ✗ Runs during render, which the server also performs.
export function Chart() {
const api = useMemo(() => createWorkerApi(), []); // useMemo runs on the server too
return <canvas />;
}
Render is a server phase; effects are not. That single distinction is the whole rule: construct in an effect, in onMounted, in onMount, or behind an explicit browser check — never at module scope and never during render.
The Guard, Per Framework
Next.js (App Router). A component that touches a worker must be a client component, and the construction must still be in an effect:
'use client';
import { useEffect, useState } from 'react';
import { getStats } from '@/lib/stats-service'; // lazy singleton, browser-only inside
export function Panel({ series }: { series: Float64Array }) {
const [range, setRange] = useState<{ min: number; max: number } | null>(null);
useEffect(() => {
let live = true;
getStats().rangeOf(series).then((r) => { if (live) setRange(r); });
return () => { live = false; };
}, [series]);
return <span>{range ? `${range.min}–${range.max}` : '—'}</span>;
}
'use client' alone is not enough: client components are still server-rendered for the initial HTML. Only the effect is skipped.
Nuxt 3. Either guard with import.meta.client or keep the component out of the server render entirely:
<script setup lang="ts">
const counts = shallowRef<Uint32Array | null>(null);
onMounted(async () => { // never runs on the server
counts.value = await getAnalysis().histogram(props.series, 64);
});
</script>
<!-- Or exclude the subtree from server rendering altogether -->
<ClientOnly><HeavyChart :series="series" /></ClientOnly>
SvelteKit. browser from $app/environment is the explicit check, and onMount never runs on the server:
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let counts: Uint32Array | null = null;
onMount(async () => { counts = await getAnalysis().histogram(series, 64); });
// For module-level code that must not run in Node:
if (browser) { /* … */ }
</script>
Angular Universal. Inject PLATFORM_ID and check isPlatformBrowser before construction; ngOnInit runs on the server, so the check is not optional.
Hydration Mismatches
The second failure is quieter. Hydration compares the server’s markup with what the client renders first; a difference makes React discard the subtree and re-render it, Vue warn and patch, and Svelte silently accept a DOM it did not produce. A worker result that arrives during hydration is a perfect way to cause one.
Three rules prevent it:
- Render the same placeholder on both sides. If the server rendered
—, the client’s first render must also be—. State that starts asnulland is filled by an effect satisfies this automatically, because effects run after hydration. - Never derive first-render output from a browser-only capability.
typeof Worker !== 'undefined' ? <Live /> : <Static />renders differently in the two environments by construction. Put the branch behind mounted state instead: startmounted = false, set it in an effect. - Do not stream a worker result into the server HTML. Tempting for a chart summary; it makes the markup depend on timing, and the client cannot reproduce it.
Choosing the Start Moment
Once the guard is right, the remaining question is when in the browser’s timeline the thread appears. Three options, in increasing order of politeness:
In the mount effect. Simplest, and the construction lands immediately after hydration — often while React is still flushing passive effects for the rest of the tree. Fine for a small worker; measurable for one that loads WebAssembly.
In an idle callback after hydration. The default recommendation:
useEffect(() => {
const id = 'requestIdleCallback' in window
? requestIdleCallback(() => void getStats(), { timeout: 2_000 })
: setTimeout(() => void getStats(), 200);
return () => ('cancelIdleCallback' in window ? cancelIdleCallback(id as number) : clearTimeout(id));
}, []);
The timeout matters: without it, a page that never goes idle never warms the worker. The scheduling primitives and their guarantees are compared in requestIdleCallback vs Worker Offloading.
On first interaction. For a feature behind a click, do nothing until the click. The first use pays 5–15 ms of construction, which is invisible next to the work that follows, and users who never open the panel never pay at all.
Sharing the Algorithm, Not the Worker
The pull towards running the same code on both sides is legitimate: a server route may want the same aggregation the browser computes in a worker. The way to get it is to keep the algorithm in a runtime-neutral module and give each environment a thin entry point.
// core/aggregate.ts — no globals, no platform APIs, importable anywhere
export function aggregate(values: Float64Array): { min: number; max: number; mean: number } {
let min = Infinity, max = -Infinity, sum = 0;
for (const v of values) { if (v < min) min = v; if (v > max) max = v; sum += v; }
return { min, max, mean: sum / values.length };
}
// browser: stats.worker.ts
import * as Comlink from 'comlink';
import { aggregate } from './core/aggregate';
Comlink.expose({ aggregate });
// server: routes/summary.ts — plain call, no threads needed for one request
import { aggregate } from './core/aggregate';
export const GET = async () => Response.json(aggregate(await loadSeries()));
The neutral module must import nothing platform-specific: no self, no window, no node: builtins, no fetch of relative URLs. That constraint is worth enforcing with a lint rule, because a single stray import is what turns a shared module into two broken builds.
If the server genuinely needs threads — a long aggregation blocking a request handler — use node:worker_threads there, with its own entry file that imports the same neutral module. Two entry points, one algorithm, zero shared platform assumptions.
Static Export and Prerendering
Fully static builds (next export, nuxt generate, SvelteKit’s prerender, Astro’s default) run the render phase at build time on a machine with no browser at all. Everything above applies with one addition: there is no request during which a client-only branch could be decided, so every page is generated with the placeholder markup.
That is usually what you want, and it makes one anti-pattern impossible to miss — a build that tries to compute chart data in a worker during generation simply fails, rather than producing subtly stale HTML. The correct division stays the same: the generator produces structure, the browser produces the numbers, and the worker is the browser’s business.
Islands architectures behave identically. An island is hydrated independently and often lazily, which shifts when the effect runs but not whether the server evaluated the module. A worker constructed at module scope in an island still breaks the build.
Gotchas
A node:worker_threads import leaking into the browser bundle. Sharing an algorithm module between server and client is good practice; sharing the entry file is not, because the Node import pulls a polyfill (or fails) in the browser build. Keep one algorithm module and two thin entry points.
Edge runtimes have neither API. Vercel Edge, Cloudflare Workers and Deno Deploy define no DOM Worker and no node:worker_threads in the general case. Code that branches on typeof Worker is fine; code that assumes Node’s threading API on the server is not.
Cross-origin isolation headers interact with server rendering. If the feature needs SharedArrayBuffer, the document response itself must carry Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp — which is a server-rendering concern, not a client one, and it applies to every route that might reach the feature. A framework that serves some routes from a CDN edge and others from an origin server needs the headers configured in both places.
ClientOnly wrappers change layout. Excluding a subtree from server rendering removes it from the first paint, which can shift content when it appears. Reserve the space with the same dimensions the component will occupy.
Double construction under React 18 StrictMode in Next dev. Effects run twice in development. A lazy singleton makes that harmless; a new Worker in the effect does not.
Performance Note
On a Next.js dashboard measured with Lighthouse on a simulated mid-tier mobile device, constructing a 240 kB module worker inside the first mount effect added 38 ms to Total Blocking Time, because it overlapped hydration. Moving the same construction into a requestIdleCallback with a 2-second timeout reduced the added blocking time to under 5 ms, with no change to how quickly the feature responded once the user actually used it — the thread was warm long before the first click.