Guide
Web Workers explained
JavaScript in the browser runs on a single main thread that also paints the page,
handles clicks, and runs your React components. A 200 ms JSON parse or image
resize is not "async" in the sense users care about — it still blocks input until
the event loop
finishes the task. Web Workers are the standard way to run JavaScript
on a separate thread so heavy CPU work does not freeze the UI. This guide covers
dedicated workers, the postMessage protocol, transferable buffers,
worker pools, how workers differ from service workers, and when they actually help
Interaction to Next Paint (INP).
What a Web Worker is
A Web Worker is a background thread with its own global scope. It runs the same
JavaScript engine (V8 in Chrome) but has no access to the DOM,
window, or most browser APIs tied to the visible page. You spawn a
worker from the main thread, send it data, it computes, and sends results back.
Workers are ideal for CPU-bound tasks: parsing large JSON or CSV,
compressing images, running cryptography, pathfinding, physics simulation chunks,
or preprocessing ML tensors. They are not a substitute for network I/O —
fetch is already non-blocking on the main thread. Do not spawn a worker
to await an API call; that adds overhead with no gain.
Three worker types exist in the platform, and confusing them causes architecture mistakes:
- Dedicated workers — one-to-one with the script that created them. This is what most apps need.
- Shared workers — shared across multiple tabs from the same origin. Rare; limited browser support and tooling.
- Service workers — network proxies for caching and push. See our service workers guide; they are not for general computation.
Creating a dedicated worker
The simplest pattern loads a separate script file:
// main.js
const worker = new Worker('/workers/parser.js');
worker.onmessage = (event) => {
console.log('Parsed rows:', event.data.rows);
};
worker.onerror = (err) => {
console.error('Worker failed:', err.message);
};
worker.postMessage({ csvText: largeString });
// workers/parser.js
self.onmessage = (event) => {
const { csvText } = event.data;
const rows = csvText.split('\n').map(line => line.split(','));
self.postMessage({ rows });
};
Modern bundlers (Vite, webpack 5, Parcel) also support inline workers
via new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }).
Module workers can use import and share code with the main bundle — useful
when the worker needs shared types or utility functions.
Workers inherit the same-origin policy: the worker script must be served from the same origin as the page, or include proper CORS headers if loaded cross-origin. Blob URLs and data URLs work for quick experiments but are harder to debug and cache.
postMessage and structured cloning
Communication is asynchronous and copy-based by default. When you
call postMessage, the browser serializes the payload using the
structured clone algorithm — a deep copy that handles most JavaScript types
(objects, arrays, Dates, Maps, ArrayBuffers) but not functions, DOM nodes, or symbols.
Copying a 50 MB ArrayBuffer on every message can cost more than the computation you offloaded. For large binary data, use transferable objects:
const buffer = new ArrayBuffer(50 * 1024 * 1024);
worker.postMessage({ buffer }, [buffer]);
// Main thread no longer owns buffer — zero-copy handoff
After transfer, the sender's reference is detached. Plan ownership carefully: either the main thread or the worker holds the buffer, not both. For two-way pipelines, allocate fresh buffers or use a ring of reusable views.
For complex apps, define a small message protocol with a type
field ({ type: 'PARSE', id: 42, payload: ... }) so one worker can handle
multiple operations and replies can be correlated. Avoid sending entire application
state on every message.
Worker pools and lifecycle
Spawning a worker has startup cost — parsing and compiling the script, creating the thread. For bursty workloads (user uploads ten files), a worker pool of two to four persistent workers outperforms create-destroy per task.
A minimal pool queues tasks and assigns them to idle workers:
- Initialize
Nworkers at app start (oftennavigator.hardwareConcurrency - 1). - Push tasks into a queue with resolve/reject callbacks.
- When a worker finishes, dequeue the next task and
postMessageto that worker. - On page unload, call
worker.terminate()on each instance.
Do not create dozens of workers — each consumes memory and competes for CPU cores. Mobile devices may expose four cores but throttle background threads aggressively. Profile on real hardware; desktop assumptions mislead.
Long-lived workers can leak memory if closures accumulate results. Restart workers periodically in very long sessions (dashboards open for days) or after processing exceptionally large payloads.
What workers cannot do
Limitations drive architecture decisions:
- No DOM — cannot call
document.querySelector, update React state directly, or touch Canvas elements on the main page. Return data; let the main thread render. - Limited APIs — no
localStorage(use IndexedDB via async APIs in the worker, or message storage requests to main). Workers can usefetch,WebSocket,crypto.subtle, andimportScriptsin classic workers. - Serialization tax — small objects are fine; huge graphs or functions cannot cross the boundary.
- Debugging friction — breakpoints live in a separate DevTools context. Name workers with a comment header for easier identification.
For near-native compute (video codecs, game physics, cryptography at scale), pair workers with WebAssembly. WASM modules run inside workers, giving C/Rust performance without blocking the main thread. The main thread stays a thin coordinator: input events in, rendered frames out.
Workers vs async/await
Developers sometimes wrap worker calls in Promises and await them from
the main thread. That is fine ergonomically — but the main thread still waits for the
reply message, which is cheap. The win is that while the worker
computes, the main thread processes input events and paints frames.
async/await alone does not parallelize CPU work. An async
function that loops ten million times still monopolizes the main thread until it
yields — and tight loops never yield. Workers are the fix when profiling shows long
tasks on the main thread in Performance panel recordings.
Scheduler.postTask and requestIdleCallback can break up small chunks of work without threads, but they do not add cores. Use them for deferrable work under 50 ms; use workers when a single task exceeds ~100 ms on mid-tier mobile.
Performance and INP
Google's Interaction to Next Paint measures how long after a tap or keypress the browser presents the next frame. Main-thread long tasks during that window inflate INP even if your average load time looks fine.
Workers help INP when:
- User interaction triggers heavy computation (filtering 100k table rows, re-encoding an image).
- Background sync processes data while the user scrolls or types.
- Games run simulation ticks off-thread and only send positions to Canvas each frame.
Workers do not help when the bottleneck is layout, style recalc, or paint — those are main-thread only. Pair worker offloading with CRP optimizations for pages that feel sluggish despite idle CPU.
Security considerations
Workers run with the same origin privileges as the page that created them. A worker
can fetch authenticated endpoints if cookies are included — treat worker
code as same-trust-level as main-thread code. Content Security Policy must allow worker
scripts: worker-src 'self' (or blob: if using inline workers).
Do not postMessage secrets (private keys, session tokens) to workers
unless necessary. Third-party worker scripts are as dangerous as third-party main-thread
scripts — supply-chain compromise in a npm worker dependency is full account compromise
in a wallet-connected dApp.
Common mistakes
- Worker for every small task — message overhead exceeds savings for <5 ms work.
- Copying megabyte buffers — use transferables or SharedArrayBuffer (with cross-origin isolation headers).
- Updating UI from worker — impossible; always route results through
onmessageon main. - Ignoring termination — orphaned workers keep running after SPA route changes unless explicitly terminated.
- Assuming parallelism on one core — two workers on a dual-core phone still time-slice; measure.
Production checklist
- Profile first — confirm main-thread long tasks before adding worker complexity.
- Start with one dedicated worker; add a pool only when queueing is proven necessary.
- Define a typed message protocol with correlation IDs for request/response pairs.
- Use transferables for ArrayBuffer, ImageBitmap, and MessagePort payloads.
- Bundle workers with
type: 'module'for tree-shaking and shared imports. - Handle
onerrorand timeouts — workers can throw on bad input silently from the main thread's perspective. - Call
terminate()on route teardown and after unrecoverable errors. - Test on mid-tier Android — INP regressions show up there first.
- Document CSP
worker-srcrequirements in deployment config.
Key takeaways
- Web Workers run JavaScript on a background thread with no DOM access.
- postMessage copies data by default; transferables enable zero-copy handoff of buffers.
- Use workers for CPU-bound work, not for network I/O or DOM updates.
- Worker pools amortize startup cost; size to
hardwareConcurrency, not task count. - Pair workers with WASM for compute-heavy modules; keep main thread as coordinator.
- Workers improve INP when interactions trigger long main-thread tasks — profile to verify.
Related reading
- JavaScript event loop explained — macrotasks, microtasks, and why async does not parallelize CPU
- Service workers and PWA explained — caching and offline; different from compute workers
- WebAssembly explained — near-native modules that run inside workers
- Core Web Vitals explained — INP thresholds and field vs lab measurement