Headline: A Web Worker is a background JavaScript thread with no DOM access, and it only improves responsiveness when the bottleneck is your own CPU-bound code — not React rendering. I moved three features off the main thread in a Next.js App Router app, and every bug I hit came from serialization, bundler paths, or React StrictMode spawning a second worker I never terminated.
I had a client-side CSV importer that froze the tab for several seconds on large files. My first instinct was to memoize harder. That was wrong: the freeze was one long task inside my own parsing code, and no amount of useMemo moves work off the thread it already runs on. A Web Worker does.
A Web Worker is a separate JavaScript thread the browser runs alongside the main thread, with its own global scope, no DOM access, and communication only through message passing. I have since moved three features into workers in a Next.js 16 App Router app — CSV parsing, client-side image resizing, and a fuzzy search index. These are the notes I wish I had on day one.
Key takeaways
- A Web Worker only helps when the long task is your own CPU-bound JavaScript. If the browser's Performance panel attributes the long task to React rendering, commit, or style recalculation, a worker moves nothing, because rendering must stay on the main thread.
-
Always construct workers with
new Worker(new URL('./parse.worker.ts', import.meta.url)). Both webpack 5 and Turbopack detect that exact expression and emit the worker as its own hashed chunk; a plain string path resolves to a 404 in a production build. -
postMessageserializes with the structured clone algorithm, which drops functions, class prototypes, DOM nodes, and getters. Sending any of those throwsDataCloneError, and sending a class instance silently produces a plain object with no methods on the other side. -
Comlink turns
postMessageinto awaited method calls using a JavaScript Proxy, so the worker exposes an object and the page callsawait api.parse(file). It costs roughly 1 KB gzipped and removes all request-ID bookkeeping. -
React StrictMode runs effects twice in development, so an uncleaned worker leaks a second thread. Return
() => worker.terminate()from the effect; without it a hot-reloading page accumulates workers until the tab is slower than before.
What does a Web Worker actually fix, and what does it not?
A Web Worker fixes main-thread blocking caused by long-running synchronous JavaScript that does not touch the DOM. Parsing, compressing, diffing, hashing, tokenizing, building a search index, decoding a large payload, and running a WebAssembly module all move cleanly.
A Web Worker does not fix slow React rendering. React's reconciliation and commit phases must run on the main thread because they write to the DOM. If an Interaction to Next Paint (INP) regression comes from rendering a 5,000-row table, the fix is virtualization or fewer components, not a worker. I wasted an afternoon before I accepted that.
The distinction is visible in a Performance trace: expand the long task and read the flame chart. If the widest frames carry your own function names, a worker will help. If they are React internals or Recalculate Style, it will not.
Workers also have no access to window, document, or localStorage. They do get fetch, IndexedDB, WebAssembly, crypto.subtle, OffscreenCanvas, and timers. The rough rule I use now: if the code would run unchanged in Node.js, it will run in a worker.
How do I create a Web Worker in the Next.js App Router?
Create the worker inside a useEffect in a Client Component, using new URL(..., import.meta.url), and terminate it in the cleanup function. The Worker constructor does not exist in Node.js, so constructing one during server rendering or at module scope throws ReferenceError: Worker is not defined at build time.
'use client';
import { useEffect, useRef } from 'react';
export function CsvImporter() {
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
const worker = new Worker(
new URL('../workers/parse.worker.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e: MessageEvent<{ rows: number }>) => {
console.log('parsed', e.data.rows);
};
workerRef.current = worker;
return () => worker.terminate();
}, []);
// ...
}
Three details matter. The new URL('./x.worker.ts', import.meta.url) expression must appear literally inside the Worker constructor — hoisting it into a variable defeats the bundler's static analysis and the file is never emitted. The { type: 'module' } option is what lets the worker use import statements. And the worker file belongs outside app/ — I keep mine in src/workers/ — so the router never tries to interpret it as a route.
The worker file itself is an ordinary module that listens on its own global scope:
// src/workers/parse.worker.ts
self.onmessage = (e: MessageEvent<{ text: string }>) => {
const rows = e.data.text.split('\n').length;
self.postMessage({ rows });
};
export {};
The trailing export {} is not decorative. It makes the file a module so TypeScript scopes self to the worker context instead of colliding with the DOM lib's global declarations.
Why does my worker throw DataCloneError?
postMessage serializes its argument with the structured clone algorithm, which copies plain data but refuses functions, Symbols, DOM nodes, and anything holding a closure. Passing one of those throws DataCloneError: Failed to execute 'postMessage'.
The quieter failure is class instances. Structured clone copies own enumerable properties and discards the prototype, so a Decimal or a parser instance arrives as a plain object with no methods, and the first method call blows up far from the postMessage line that caused it. I now send only JSON-shaped data across the boundary and rehydrate on the receiving side.
Transferable objects are the escape hatch for large payloads. An ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, or ReadableStream can be transferred instead of copied, which hands ownership to the other thread in constant time and leaves the original detached:
const buffer = await file.arrayBuffer();
worker.postMessage({ buffer }, [buffer]);
// buffer.byteLength is now 0 on this thread — ownership moved
Forgetting that second argument is the difference between moving a pointer and copying every byte. For the image-resize worker this was the single change that made the interaction feel instant, because a multi-megabyte copy was happening on the main thread before the worker ever started.
When should I use Comlink instead of raw postMessage?
Comlink is a roughly 1 KB library from the Chrome team that wraps postMessage in a JavaScript Proxy so the worker looks like an awaitable object. Reach for it as soon as the worker has more than one operation, because hand-rolled routing means inventing request IDs, a pending-promise map, and a discriminated union of message types — then maintaining all three.
// src/workers/search.worker.ts
import * as Comlink from 'comlink';
const api = {
async buildIndex(docs: Doc[]) { /* ... */ },
async query(term: string) { return search(term); },
};
export type SearchApi = typeof api;
Comlink.expose(api);
import * as Comlink from 'comlink';
import type { SearchApi } from '../workers/search.worker';
const worker = new Worker(
new URL('../workers/search.worker.ts', import.meta.url),
{ type: 'module' }
);
const api = Comlink.wrap<SearchApi>(worker);
const results = await api.query('next.js');
Two Comlink rules I learned by breaking them. Callbacks must be wrapped as Comlink.proxy(cb), because a bare function cannot be cloned. And transferables need Comlink.transfer(value, [buffer]) — Comlink will not infer a transfer list, so the zero-copy win silently disappears if you skip it.
Use import type for SearchApi so the worker module is never pulled into the main bundle, while the main thread still gets full autocomplete with every return type wrapped in a Promise.
Should I use a Web Worker or scheduler.yield()?
Use scheduler.yield() when the work must touch the DOM or is only moderately long; use a Web Worker when the work is pure computation measured in hundreds of milliseconds. scheduler.yield() is a Scheduling API method that returns a Promise and lets the browser service pending input before the same function continues — it splits one long task into several short ones on the same thread rather than moving the work.
for (const [i, item] of items.entries()) {
render(item); // touches the DOM, must stay on the main thread
if (i % 50 === 0) await scheduler.yield();
}
| Aspect | Web Worker | scheduler.yield() |
|---|---|---|
| Thread | Separate thread; main thread stays free | Same thread; task split into chunks |
| DOM access | None | Full |
| Setup cost | Spawn a thread and parse a second bundle | One await
|
| Data cost | Structured clone unless transferred | Zero — same memory |
| Best for | Parsing, hashing, indexing, WebAssembly | Long loops that build or mutate UI |
The setup cost is real: spawning a worker means the browser creates a thread and parses a second bundle. For work measured in a couple of milliseconds, the round trip is pure overhead. I only move something into a worker once I can see it as a long task — over 50 ms — in a trace.
What broke for me the first time?
StrictMode spawned two workers. React StrictMode mounts, unmounts, and remounts every component in development. My effect created a worker and returned nothing, so each hot reload left an orphaned thread holding its index in memory. Returning () => worker.terminate() fixed it, and it is also the correct production behavior when a user navigates away mid-parse.
A hoisted URL produced a 404 in production. Development worked; the deployed build requested a worker path that did not exist and got an HTML error page back. The cause was refactoring new URL(...) into a shared constant, which defeats the bundler's static detection. Keep the expression inline inside the constructor.
Errors vanished. An exception thrown inside a raw worker does not reject anything on the main thread; it fires an error event on the worker object. I now always attach worker.onerror, and with Comlink I wrap calls in try/catch — remembering that only the message and stack survive the boundary, so a custom error class arrives as a generic object unless you register a Comlink.transferHandlers entry.
SharedArrayBuffer was not an option. Sharing memory between threads without copying requires cross-origin isolation: Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: require-corp. Those headers broke embedded third-party widgets on the same page, so I stayed with transferables. If your app embeds anything cross-origin, budget real time before assuming SharedArrayBuffer is available.
One worker was enough. I built a pool sized by navigator.hardwareConcurrency before measuring, then found a single worker already removed every long task from the trace. A pool is worth it when you have genuinely parallel independent chunks; it is not a default.
FAQ
Q: Do Web Workers work with Server Components in the Next.js App Router?
A: Not directly. Worker is a browser API, so the file that constructs it needs the 'use client' directive, and construction must happen inside useEffect or an event handler so it never runs during server rendering. A Server Component can freely render the Client Component that owns the worker.
Q: Does moving work to a Web Worker improve INP?
A: Only when the interaction is blocked by CPU-bound JavaScript you control. Interaction to Next Paint measures the delay from an interaction to the next frame, so moving a 400 ms parse off the main thread helps directly, while a slow React commit is unaffected because rendering cannot leave the main thread.
Q: Does Turbopack support new Worker(new URL(...))?
A: Yes. Turbopack, the default bundler for next dev and next build in Next.js 16, detects the new URL('./file', import.meta.url) pattern inside a Worker constructor and emits the worker as its own chunk, the same way webpack 5 does.
Q: What is the difference between a Web Worker and a Service Worker?
A: A Web Worker is a background compute thread owned by one page and terminated with it. A Service Worker is a network proxy between the page and the network that persists across page loads and powers offline caching and push notifications; it is not a place to run heavy computation for the current page.
Q: Can I share state between the main thread and a worker without copying?
A: Yes, with SharedArrayBuffer, but the page must be cross-origin isolated via COOP and COEP response headers. Without those headers, use transferable objects, which move ownership of an ArrayBuffer in constant time instead of sharing it.
Closing
The mental model that finally made workers easy: a worker is a tiny server that happens to run in the same tab. You send it a request, it answers with data, and everything crossing the gap must survive serialization. Once I stopped sending rich objects across the boundary and started sending buffers and plain JSON, the confusing failures went away.
The discipline is measurement first. Open a trace, find the long task, read the flame chart, and only then reach for a thread. Web Workers fix one specific problem very well, and they add real complexity to everything else.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)