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.

Which phases run on the server and which can touch a worker A timeline of five phases. Module evaluation and render run in both Node and the browser, so neither may construct a worker. Markup is streamed to the client, then hydration attaches event handlers in the browser only. Effects and mount hooks run in the browser only and are the first safe place to construct a worker, though starting there still competes with hydration. Idle time after hydration is marked as the preferred moment, and first interaction is marked as the latest acceptable moment. Where the worker may be constructed module evaluation Node + browser no Worker in Node render Node + browser useMemo runs here too hydration browser only busiest main-thread phase effects · onMount browser only safe, but early ReferenceError: Worker is not defined idle after hydration, or first interaction the preferred moment — competes with nothing Render is a server phase. Effects are not. Every guard in every framework is a restatement of that.
The olive box is correct and the mustard box is better: legal early is still early, and hydration has no spare milliseconds.

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:

  1. Render the same placeholder on both sides. If the server rendered , the client’s first render must also be . State that starts as null and is filled by an effect satisfies this automatically, because effects run after hydration.
  2. 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: start mounted = false, set it in an effect.
  3. 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.
Two hydration timelines, one with a mismatch Two sequences. In the mismatched sequence, the server sends markup containing a dash placeholder, the client's first render checks for worker support and produces a live chart element instead, hydration finds different markup and discards the subtree, and the browser re-renders it from scratch, costing extra layout and paint. In the correct sequence, the server sends the same dash placeholder, the client's first render also produces the dash, hydration matches and attaches handlers cheaply, and only afterwards does the effect start the worker and swap in the result. First client render must equal the server's mismatch server sends "—" client renders a chart subtree discarded re-render extra layout and paint, and any DOM state in that subtree is thrown away correct server sends "—" client renders "—" hydration matches effect → worker the result swaps in after hydration is complete — one cheap update, no discarded DOM A worker can only ever affect the second update, never the first.
The rule is small enough to review for: nothing a worker produces may influence the first paint of a hydrated page.

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.


Four moments a worker could start, and what each costs Four candidate start moments. Module scope fails outright because Worker is undefined in Node. The mount effect is safe but overlaps hydration and adds to total blocking time. An idle callback after hydration costs almost nothing and leaves the thread warm. First interaction costs nothing until the feature is used, at which point construction is invisible next to the work. When to construct, from worst to best module scope throws in Node never do this mount effect safe, but early overlaps hydration idle after hydration warm and free the default choice first interaction nothing up front best for rare features Only the first column is a bug. The other three are a trade between warm-up and page load. Each step is cheap; skipping one is what makes the next expensive.
The two olive columns differ only in whether the feature is common enough to warrant warming.

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.

Frequently Asked Questions

Can I use node:worker_threads to run the same worker code on the server?
Only if you wrote the worker for both environments. node:worker_threads has a different construction API, a different message-port object, and no self, importScripts, OffscreenCanvas or DOM-adjacent globals. Sharing pure computation between the two is realistic — export the algorithm from a neutral module and give each environment its own thin worker entry point — but sharing the worker file is not.
When should the worker start: during hydration, or after it?
After. Constructing a worker costs 5–15 ms of main-thread time and competes directly with hydration, which is already the busiest phase of the page’s life and the one that decides Interaction to Next Paint. Start it on the first interaction that needs it, or in a requestIdleCallback scheduled once hydration has finished.

See also