Web Workers in the Next.js App Router: Our Field Notes on import.meta.url, DataCloneError, and What a Worker Cannot Fix
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗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. Our team moved three features into workers in a Next.js 16 App Router application: CSV parsing, client-side image resizing, and a fuzzy search index.
Every bug we hit came from one of three places — serialization across the thread boundary, bundler path resolution, or React StrictMode spawning a second worker that was never terminated. These are the notes we wish we had on day one.
Key takeaways
A Web Worker only helps when the long task is CPU-bound JavaScript your team owns. 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, while a plain string path resolves to a 404 in a production build.
postMessage serializes with the structured clone algorithm, which drops functions, class prototypes, DOM nodes, and getters. Sending any of those throws DataCloneError, and sending a class instance silently produces a plain object with no methods on the receiving thread.
Comlink turns postMessage into awaited method calls using a JavaScript Proxy, so the worker exposes an object and the page calls await 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. Returning () => worker.terminate() from the effect is both the development fix and the correct production behaviour when a user navigates away mid-task.
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 regression comes from rendering a 5,000-row table, the fix is virtualization or fewer components, not a worker.
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 have no access to window, document, or localStorage. They do get fetch, IndexedDB, WebAssembly, crypto.subtle, OffscreenCanvas, and timers. The rule our team uses: if the code would run unchanged in Node.js, it will run in a worker.
How do we 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.
Three details matter. The new URL('./x.worker.ts', import.meta.url) expression must appear literally inside the Worker constructor, because hoisting it into a variable defeats the bundler's static analysis and the file is never emitted. The option { type: 'module' } is what allows import statements inside the worker. And the worker file belongs outside the app directory — we keep ours in src/workers — so the router never interprets it as a route.
Inside the worker file, a 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 library's global declarations.
Why does a 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 fails far from the postMessage line that caused it. We 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 by passing it in the second argument to postMessage, which hands ownership to the other thread in constant time and leaves the original detached with a byteLength of zero.
Forgetting that second argument is the difference between moving a pointer and copying every byte. For our 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 we 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.
The worker calls Comlink.expose(api) and exports the type of that object; the page calls Comlink.wrap of that type on the Worker instance and then awaits ordinary method calls such as api.query('next.js').
Two Comlink rules our team 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]), because Comlink will not infer a transfer list, so the zero-copy win silently disappears if you skip it.
Use import type for the exposed API type 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 we 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, splitting one long task into several short ones on the same thread rather than moving the work.
Thread: a Web Worker runs on a separate thread and leaves the main thread free, while scheduler.yield() stays on the same thread and only chunks the task.
DOM access: a Web Worker has none, scheduler.yield() has full access.
Setup cost: a Web Worker spawns a thread and parses a second bundle, scheduler.yield() costs a single await.
Data cost: a Web Worker pays structured clone unless you transfer, scheduler.yield() pays nothing because memory is shared.
Best for: workers suit parsing, hashing, indexing, and WebAssembly, while scheduler.yield() suits 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. We only move something into a worker once it shows up as a long task, over 50 ms, in a trace.
What broke for us the first time?
StrictMode spawned two workers. React StrictMode mounts, unmounts, and remounts every component in development. An effect that created a worker and returned nothing left an orphaned thread holding its index in memory on every hot reload. Returning () => worker.terminate() fixed it.
A hoisted URL produced a 404 in production. Development worked, but the deployed build requested a worker path that did not exist and received an HTML error page. The cause was refactoring new URL(...) into a shared constant, which defeats the bundler's static detection.
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. We now always attach worker.onerror, and with Comlink we wrap calls in try/catch, remembering that only the message and stack survive the boundary unless a Comlink.transferHandlers entry is registered.
SharedArrayBuffer was not an option. Sharing memory between threads without copying requires cross-origin isolation through Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Those headers broke embedded third-party widgets on the same page, so we stayed with transferables.
One worker was enough. We 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 only when the chunks are genuinely parallel and independent.
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 we 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 for our team: 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.
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.
Further Reading
AI Engineering
Hybrid Search in Postgres with pgvector: Our Field Notes on HNSW, tsvector, and Reciprocal Rank Fusion
pgvector is a Postgres extension that adds vector column types and approximate-nearest-neighbour indexes. We shipped a RAG retrieval pipeline on it, watched it miss exact error codes and SKUs, and fixed it by fusing vector similarity with Postgres full-text search using Reciprocal Rank Fusion. These are our notes on HNSW versus IVFFlat, the 2000-dimension index limit, and the WHERE clause that returned fewer rows than the LIMIT asked for.
Developer Tooling
Turborepo in a Real Monorepo: Field Notes on Cache Misses, NEXT_PUBLIC Poisoning, and the tasks Key That Replaced pipeline
Turborepo is a task runner that hashes each script's inputs and replays cached outputs instead of re-running the work. We moved a two-app monorepo onto Turborepo 2 — here is why the cache never hit, why a cache hit once shipped staging URLs to production, and why --affected silently builds everything on a shallow CI clone.