High-Performance Computation Patterns
Every JavaScript application eventually meets a computation that does not fit inside a 16ms frame: a 40MB CSV export, a convolution kernel over a 12-megapixel photo, a physics step, a codec written in Rust. This guide is the architectural reference for moving that work off the main thread and keeping it there — covering the offload decision, zero-copy data transfer, scheduling and backpressure, WebAssembly execution, off-thread rendering, and service-worker precomputation. It is written for frontend engineers who already know how to construct a Worker and now need the throughput, memory, and compatibility characteristics of each pattern. It sits alongside the other two top-level guides on JavaScript Web Workers & Background Processing: Web Workers Architecture & Communication, which covers the communication primitives themselves, and Debugging, Profiling & Production Optimization, which covers observing all of it in production.
Modern JavaScript applications demand deterministic concurrency. The main thread must remain unblocked for rendering and user input. Background processing shifts heavy computation to isolated execution contexts, and choosing the right strategy for each workload type is what separates applications that feel instant from ones that stutter under load.
The Off-Thread Execution Map
Before writing a line of worker code, decide which execution context the work belongs in. The browser offers four distinct places to run JavaScript, and they differ not in speed but in what they can reach and how long they survive.
| Context | Lifetime | DOM access | Best for | Key constraint |
|---|---|---|---|---|
| Main thread | Page lifetime | Full | Layout, input, anything under 4ms | Every millisecond spent here is a millisecond not spent painting |
| Dedicated worker | Until terminate() or page unload |
None | Parsing, transforms, simulation, Wasm | One owner; communication only via postMessage |
| Shared worker | While any tab holds a port | None | One cache or connection shared across tabs | Uneven debugging support; port bookkeeping is manual |
| Service worker | Event-driven, killed when idle | None | Precomputation, response rewriting, cached results | Can be terminated mid-task; never hold long-lived state |
The mental model that matters most is the boundary, not the thread. A dedicated worker is a separate JavaScript realm with its own heap, its own event loop, and its own microtask queue. Nothing is shared implicitly. A value crosses the boundary in exactly one of three ways: it is copied (structured clone), its ownership is handed over (a transfer list), or it lives in memory both sides can address (SharedArrayBuffer). Every performance question on this page reduces to which of those three you picked.
The API surface inside a worker is narrower than the window but wider than most developers expect. fetch, WebSocket, IndexedDB, crypto.subtle, TextEncoder/TextDecoder, URL, WebAssembly, ImageBitmap, and OffscreenCanvas are all available. What is missing is anything that touches layout: window, document, localStorage, alert, and — a detail that catches almost everyone — requestAnimationFrame in dedicated workers.
Thread Isolation and the Offload Threshold
The browser enforces strict execution boundaries between UI rendering and background computation. Each worker runs in a separate event loop. This guarantees that heavy CPU tasks never stall paint cycles or input handling.
Workers operate in a sandboxed environment. Direct DOM manipulation is explicitly forbidden. Accessing window, document, or layout APIs throws immediate runtime errors. This design prevents race conditions and layout thrashing, and it is the reason worker code tends to be more testable than the main-thread code it replaces: a function that cannot touch the DOM has an explicit input and an explicit output.
Communication relies entirely on asynchronous message passing. The postMessage API serialises payloads using the structured clone algorithm, which is covered end to end in Message Passing Strategies. Cross-origin isolation via Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers is required before SharedArrayBuffer becomes available.
Isolation forces developers to design stateless or explicitly synchronised architectures. Data flows unidirectionally between threads. State mutations occur in one context and are reflected via immutable snapshots.
The 4ms rule
Offloading is not free. A round trip through postMessage costs roughly 0.1–0.5ms of pure messaging overhead on a warm worker, plus whatever serialisation the payload demands. That sets a floor: if the synchronous version of your function finishes in under about 4ms, moving it to a worker makes the page slower in wall-clock terms and no smoother in perceived terms, because a 4ms task never missed a frame in the first place.
Measure before you move. The recipe is deliberately boring:
// bench.ts — run this against a production-sized payload, not a fixture
const samples: number[] = [];
for (let i = 0; i < 30; i++) {
const start = performance.now();
transformRows(payload); // the candidate for offloading
samples.push(performance.now() - start);
}
samples.sort((a, b) => a - b);
const median = samples[Math.floor(samples.length / 2)];
const p95 = samples[Math.floor(samples.length * 0.95)];
// Decide on p95, not the mean: jank is a tail-latency problem.
console.log({ median, p95, offload: p95 > 4 });
Run this on a mid-tier device or with CPU throttling set to 4× in DevTools. Desktop timings systematically under-report the problem: the same 8ms transform becomes a 35ms frame killer on a mid-range Android handset. The Debugging, Profiling & Production Optimization guide covers the tooling side, and Measuring Structured Clone Cost with performance.now() shows how to instrument the transfer itself rather than just the computation.
A Minimal, Typed Offload
Everything else on this page is a variation of the pattern below: a typed request/response protocol with one in-flight Promise per message id. It is the smallest complete offload that is still safe to ship — it handles errors, it cleans up its listeners, and it never leaks a pending Promise.
// protocol.ts — shared by both sides so the payload shape cannot drift
export type TaskRequest = {
id: number;
kind: 'histogram';
buffer: ArrayBuffer; // transferred, not copied
};
export type TaskResponse =
| { id: number; ok: true; result: Uint32Array }
| { id: number; ok: false; error: { message: string; stack?: string } };
// client.ts — main thread
import type { TaskRequest, TaskResponse } from './protocol';
export class ComputeClient {
private worker = new Worker(new URL('./compute.worker.ts', import.meta.url), {
type: 'module', // module workers allow static imports inside the worker
});
private nextId = 1;
private pending = new Map<number, {
resolve: (v: Uint32Array) => void;
reject: (e: Error) => void;
}>();
constructor() {
this.worker.addEventListener('message', (event: MessageEvent<TaskResponse>) => {
const entry = this.pending.get(event.data.id);
if (!entry) return; // late reply for a cancelled task
this.pending.delete(event.data.id); // delete BEFORE settling to avoid leaks
if (event.data.ok) entry.resolve(event.data.result);
else entry.reject(Object.assign(new Error(event.data.error.message), event.data.error));
});
// A worker-level error kills every in-flight task; fail them all explicitly
// rather than leaving callers awaiting a Promise that can never settle.
this.worker.addEventListener('error', (event) => {
const err = new Error(event.message || 'worker crashed');
for (const [, entry] of this.pending) entry.reject(err);
this.pending.clear();
});
}
histogram(buffer: ArrayBuffer): Promise<Uint32Array> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
const request: TaskRequest = { id, kind: 'histogram', buffer };
// The second argument is the transfer list: ownership of `buffer` moves to
// the worker and `buffer.byteLength` becomes 0 on this side immediately.
this.worker.postMessage(request, [buffer]);
});
}
dispose() {
this.worker.terminate();
for (const [, entry] of this.pending) entry.reject(new Error('client disposed'));
this.pending.clear();
}
}
// compute.worker.js — worker side, plain JS so the module semantics stay obvious
self.onmessage = (event) => {
const { id, buffer } = event.data;
try {
const pixels = new Uint8ClampedArray(buffer);
const bins = new Uint32Array(256);
// Single pass over the luminance channel — no allocation inside the loop.
for (let i = 0; i < pixels.length; i += 4) {
const luma = (pixels[i] * 77 + pixels[i + 1] * 151 + pixels[i + 2] * 28) >> 8;
bins[luma]++;
}
// Transfer the result buffer back so the reply is also zero-copy.
self.postMessage({ id, ok: true, result: bins }, [bins.buffer]);
} catch (err) {
// Error instances do not structured-clone with their stack; flatten first.
self.postMessage({ id, ok: false, error: { message: err.message, stack: err.stack } });
}
};
Three details in that example are the ones people get wrong. The pending map entry is deleted before the promise settles, so a synchronous throw in a .then handler cannot leave a stale entry behind. The error listener rejects everything in flight, because a worker that has thrown at the top level will never reply. And the error is flattened into a plain object — Error instances technically clone in modern browsers, but stacks and custom fields are routinely lost, which is exactly the failure discussed in Structured Error Serialization Across Threads.
Zero-Copy Data Transfer and Serialization
Inter-thread communication defaults to structured cloning. This algorithm recursively copies objects, preserving internal references and handling circular structures. It incurs linear time complexity relative to payload size and, critically, it runs synchronously on the sending thread.
Structured cloning a 5MB object graph can block the main thread for 15–30ms on mid-tier hardware. High-frequency transfers trigger garbage collection pauses, because every clone allocates a full second copy that becomes garbage as soon as the receiver is done with it. Under sustained load, memory throughput — not CPU — becomes the bottleneck.
Transferable objects bypass serialisation entirely. Ownership of ArrayBuffer, MessagePort, ImageBitmap, ReadableStream, WritableStream, and OffscreenCanvas instances moves between threads: the receiving realm gets the same backing memory, and the sending reference is detached. Reading a detached buffer throws, which is a feature — it turns a data race into a loud, immediate error.
Zero-copy transfers complete in well under 1ms regardless of buffer size, because only a pointer and a length change hands. The strategy eliminates GC pressure and keeps frame budgets deterministic. Always pass transfer lists explicitly; the browser will never infer them for you.
The Transferable Objects & Zero-Copy reference documents the full list of transferable types and the browser compatibility notes, and SharedArrayBuffer & Atomics covers the third option — memory that is never handed over at all.
| Strategy | Payload | Main-thread block | Notes |
|---|---|---|---|
| Structured clone | 5 MB object | 15–30 ms | Scales linearly with graph depth |
| Transferable ArrayBuffer | 50 MB | <1 ms | Ownership moves; source detaches |
| SharedArrayBuffer | any | 0 ms (no copy) | Requires COOP/COEP headers |
| String via postMessage | 2 MB JSON string | 8–12 ms | Encoding to an ArrayBuffer first is faster |
SharedArrayBuffer requires cross-origin isolation. Your server must send both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers on every document that uses SharedArrayBuffer. Without these headers, SharedArrayBuffer is undefined in modern browsers as a Spectre mitigation. Verify isolation with self.crossOriginIsolated before constructing shared memory — and remember that require-corp also forces every cross-origin subresource (fonts, analytics scripts, embedded video) to opt in with CORP or CORS headers, which is usually the real deployment cost.
// main-thread.js
export class TransferableMessageHandler {
constructor(workerUrl) {
this.worker = new Worker(workerUrl, { type: 'module' });
this.worker.onmessage = (e) => this.handleResponse(e.data);
}
sendPayload(buffer, transfer = true) {
if (transfer && buffer instanceof ArrayBuffer) {
this.worker.postMessage({ type: 'process', buffer }, [buffer]);
// buffer is now detached on the main thread: byteLength === 0
} else {
this.worker.postMessage({ type: 'process', buffer });
}
}
handleResponse(data) {
console.log('Worker returned:', data);
}
terminate() {
this.worker.terminate();
}
}
// worker.js
self.onmessage = (e) => {
const { type, buffer } = e.data;
if (type === 'process') {
const view = new Uint8Array(buffer);
for (let i = 0; i < view.length; i++) {
view[i] ^= 0xFF; // XOR inversion, in place — no second allocation
}
self.postMessage({ status: 'complete', size: buffer.byteLength }, [buffer]);
}
};
The pattern to internalise is ownership ping-pong: the main thread transfers a buffer in, the worker mutates it in place, and the same buffer is transferred back. No copy exists at any point, and the buffer can be reused for the next task instead of reallocated. Buffer pooling on top of this removes almost all remaining GC pressure from a streaming pipeline.
Implementing Data Parsing & Serialization for binary payloads requires careful chunking. Large datasets should flow through streaming parsers rather than monolithic buffers — see Streaming JSON Parsing with Transferable Chunks for the incremental variant.
Performance Envelope: When Offloading Pays
Offloading is an engineering trade, not a free win. The table below gives the shape of the trade for the workloads this site covers. Numbers are order-of-magnitude figures measured on a mid-tier 2023 laptop with 4× CPU throttling; treat them as ratios to reproduce, not constants to quote.
| Workload | Main-thread cost | In a warm worker | Verdict |
|---|---|---|---|
JSON.parse of 200KB |
~2 ms | ~2 ms + 0.4 ms messaging | Keep on the main thread |
JSON.parse of 8MB |
90–160 ms | same compute, 0 ms blocking | Offload — the classic case |
| CSV → typed rows, 50k rows | 120–400 ms | same, streamed in chunks | Offload with chunked replies |
| 3×3 convolution, 12MP image | 250–600 ms | same, with zero-copy ImageData |
Offload; transfer the buffer |
| Wasm codec decode, 2MB | 40–120 ms | same, no JIT warm-up | Offload; instantiate once per worker |
| Chart re-layout at 60fps | Drops frames | Steady with OffscreenCanvas | Offload the render loop |
| Cryptographic hash of 500 bytes | 0.05 ms | 0.05 ms + 0.4 ms messaging | Never offload |
Three cost centres decide the outcome. Spawn cost: creating a worker means a new realm, a fresh V8 isolate, and script parsing — roughly 50–150ms for a non-trivial bundle on mid-tier hardware, which is why pools exist. Transfer cost: linear in payload size for structured clone, near-constant for transfers. Compute cost: identical on both threads, since a worker gets the same optimising compiler. Offloading only wins when compute dominates the sum of the other two, or when the blocking nature of the work matters more than its total duration.
There is a fourth, subtler cost: latency. A task dispatched to a busy pool waits behind whatever is already running. For interactive work where the user is watching a spinner, queue depth is the number that matters, not throughput. Cap the queue, surface its depth, and prefer cancelling stale work over letting it drain.
Security and Browser Compatibility
Two things gate what you can actually ship: cross-origin isolation and the older Safari versions still in the field.
Cross-origin isolation is all-or-nothing per document. Once require-corp is on, every cross-origin resource must opt in, or it simply fails to load. In practice teams roll it out behind a flag, verify with self.crossOriginIsolated, and keep a non-shared fallback path that uses transferable buffers instead. That fallback is not a compromise — for one-way pipelines it is usually the faster design anyway, as postMessage vs SharedArrayBuffer: When to Choose Each works through in detail.
| API | Chrome | Firefox | Safari | Notes |
|---|---|---|---|---|
Dedicated Worker |
Yes | Yes | Yes | Universal; classic scripts everywhere |
Module workers (type: 'module') |
80+ | 114+ | 15+ | Firefox was last; check your build target |
Transferable ArrayBuffer |
Yes | Yes | Yes | Universal |
ImageBitmap transfer |
50+ | 42+ | 15+ | Decode off-thread with createImageBitmap |
OffscreenCanvas (2D + WebGL) |
69+ | 105+ | 16.4+ | Feature-detect; keep a main-thread fallback |
SharedArrayBuffer |
68+ (COOP/COEP since 92) | 79+ | 15.2+ | Requires cross-origin isolation |
Atomics.waitAsync |
87+ | 127+ | 16.4+ | The only safe wait on the main thread |
| Wasm SIMD | 91+ | 89+ | 16.4+ | Ship a scalar build as fallback |
navigator.hardwareConcurrency |
Yes | Yes | Yes | Safari reports a capped value; treat as a hint |
Atomics.wait throws a TypeError when called on the main thread — it is only legal inside a worker. Use Atomics.waitAsync for main-thread coordination. Similarly, requestAnimationFrame does not exist in dedicated workers: drive worker render loops from a message, a setTimeout, or an rAF tick forwarded from the page. Safari also reports a deliberately coarse navigator.hardwareConcurrency, so clamp it into a sane range (for example Math.min(Math.max(n, 2), 8)) instead of trusting it verbatim.
OffscreenCanvas needs a feature test and a main-thread fallback, and every SharedArrayBuffer cell is gated on cross-origin isolation regardless of engine version.Workload Playbooks
Each workload family below has its own dedicated guide. What follows is the decision each one turns on, so you can route to the right one without reading all five.
Structured data: JSON, CSV and binary
Parsing is the archetypal offload because the cost is proportional to bytes and the output is a plain value. The decision is chunking: parse a monolithic string and you pay one large clone, stream it and you pay many small ones but never block. Data Parsing & Serialization covers the parser-side mechanics; CSV & JSON Transform Pipelines covers chunked streaming, where each chunk transfers independently and the main thread reassembles results incrementally. For teams converting an existing synchronous codebase, Migrating Synchronous Loops to Web Workers Safely is a step-by-step refactoring playbook.
A rule of thumb: decode text to an ArrayBuffer with TextEncoder on the sending side and parse from the buffer in the worker. Transferring 2MB of UTF-8 bytes costs under a millisecond; cloning the same content as a JavaScript string costs 8–12ms.
Pixels: image processing and filters
Pixel work is where zero-copy pays the most, because ImageData.data is already a typed array over an ArrayBuffer. Image Processing in Workers covers convolution kernels, colour grading, and edge detection over ImageData buffers; Using Transferable Objects for Canvas ImageData covers the transfer mechanics specifically. Where the source is a file or a <img>, createImageBitmap() decodes off-thread and produces a transferable ImageBitmap, removing the decode from the main thread as well.
Compiled code: WebAssembly
WebAssembly unlocks a second tier of performance for compute-bound code. Algorithms written in Rust, C, or C++ compile to .wasm binaries that the engine executes without JIT warm-up and with a predictable memory layout.
Instantiating a Wasm module is itself a blocking operation when performed on the main thread. Moving WebAssembly.instantiateStreaming() into a worker means compilation and linking never compete with rendering. Once the module is ready, the worker holds the instance for the lifetime of the pool. Better still, a compiled WebAssembly.Module is structured-cloneable: compile once, postMessage the module to every worker, and each one instantiates in well under a millisecond. WebAssembly in Workers covers streaming compilation, memory growth, SIMD intrinsics, and sharing linear memory across workers.
Wasm is not universally faster than optimised JavaScript. V8's JIT compiler closes the gap for simple numeric loops. Wasm wins decisively for algorithms with predictable memory access patterns, explicit SIMD, or when porting mature C/C++ libraries (codecs, physics engines, cryptography). Always benchmark with realistic production payloads before committing to a Wasm build pipeline.
Frames: OffscreenCanvas rendering
Canvas operations traditionally block the main thread. Pixel manipulation, compositing, and frame extraction consume significant CPU cycles. OffscreenCanvas moves rendering to a background thread safely: the main thread calls transferControlToOffscreen() once, hands the resulting object to the worker in a transfer list, and every subsequent draw happens off-thread while the on-screen canvas updates automatically.
OffscreenCanvas Rendering covers ImageBitmapRenderingContext, WebGL in workers, and the Safari compatibility story; Rendering Charts Off the Main Thread applies it to data visualisation, where layout recalculation is usually the real cost.
// main-thread.js
export class OffscreenCanvasRenderer {
constructor(canvasElement, workerUrl) {
this.canvas = canvasElement;
// One-way door: after this call the element can never get a 2D context here.
this.offscreen = canvasElement.transferControlToOffscreen();
this.worker = new Worker(workerUrl, { type: 'module' });
this.worker.postMessage({ type: 'init', canvas: this.offscreen }, [this.offscreen]);
}
updateFrame(data) {
this.worker.postMessage({ type: 'render', payload: data });
}
destroy() {
this.worker.terminate();
}
}
// worker.js
// Note: requestAnimationFrame is NOT available in dedicated workers.
// Use setInterval, a message-driven loop, or an rAF tick forwarded from the page.
let ctx = null;
let latestPayload = null;
self.onmessage = (e) => {
if (e.data.type === 'init') {
ctx = e.data.canvas.getContext('2d');
drawLoop();
} else if (e.data.type === 'render') {
// Keep only the newest payload: rendering a stale frame is wasted work.
latestPayload = e.data.payload;
}
};
function drawLoop() {
if (ctx && latestPayload) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// Render logic here
}
// Cooperative scheduling: yield every ~16ms
setTimeout(drawLoop, 16);
}
Network-adjacent work: service workers
Service workers occupy a different position in the off-thread hierarchy. Rather than receiving tasks dispatched from the page, they intercept network requests and can perform precomputation, response transformation, and aggressive caching entirely off the critical path.
Practical patterns include pre-warming a computation cache during the install event, transforming API responses (decompression, schema normalisation) before handing them to the page, and serving a stale precomputed result while a fresh computation runs in the background. The critical constraint is lifetime: the browser can terminate an idle service worker at any moment, so anything long-running must be wrapped in event.waitUntil() and must be safe to restart from scratch.
Service Workers for Computation details Cache API strategies and coordination with dedicated workers; Dedicated vs Service Workers for CPU Tasks is the decision guide when both look plausible.
Advanced Patterns: Pooling, Scheduling and Backpressure
A single worker gets you off the main thread. Getting predictable behaviour under sustained load takes three more patterns.
Pool sizing and warm start
Instantiating workers carries measurable overhead: thread creation, isolate initialisation, and script parsing consume roughly 50–150ms per instance on mid-tier devices. Unmanaged pools quickly exhaust memory and trigger aggressive garbage collection, since each worker carries its own heap and its own copy of any module it imports.
Size the pool at navigator.hardwareConcurrency, clamped into a sane range, and reuse instances rather than spawning per task. Static pools reserve threads upfront for predictable latency; dynamic pools grow under load and recycle idle workers on a timeout, trading a cold-start penalty for a smaller resident footprint. Worker Pool Management explains the sizing heuristics, and Dynamic vs Fixed-Size Worker Pools compares the two directly.
Termination guarantees matter for memory safety. Detached workers retain references to their message ports until explicitly freed. An explicit terminate() severs those connections and releases the native thread handle immediately — it does not run cleanup code inside the worker, so any flush-to-storage must happen before you call it.
// main-thread.ts
export class WorkerPoolManager {
private pool: Worker[] = [];
private taskQueue: Array<{
id: string;
payload: unknown;
resolve: (v: unknown) => void;
reject: (e: unknown) => void;
}> = [];
private activeWorkers = new Set<Worker>();
private readonly maxWorkers: number;
private readonly idleTimeout: number;
private idleTimers = new Map<Worker, ReturnType<typeof setTimeout>>();
private readonly scriptURL: string;
constructor(scriptURL: string, maxWorkers = navigator.hardwareConcurrency, idleTimeout = 30000) {
this.scriptURL = scriptURL;
this.maxWorkers = maxWorkers;
this.idleTimeout = idleTimeout;
}
async dispatch<T>(task: { id: string; payload: unknown }): Promise<T> {
return new Promise((resolve, reject) => {
const worker = this.acquireWorker();
if (!worker) {
// Every worker is busy: park the task until one is recycled.
this.taskQueue.push({ id: task.id, payload: task.payload, resolve: resolve as (v: unknown) => void, reject });
return;
}
this.routeTask(worker, task, resolve as (v: unknown) => void, reject);
});
}
private acquireWorker(): Worker | null {
if (this.pool.length > 0) return this.pool.pop()!;
if (this.activeWorkers.size < this.maxWorkers) {
return this.spawnWorker();
}
return null;
}
private spawnWorker(): Worker {
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
this.activeWorkers.add(worker);
return worker;
}
private routeTask(
worker: Worker,
task: { id: string; payload: unknown },
resolve: (v: unknown) => void,
reject: (e: unknown) => void
) {
// One listener per task, removed on completion: leaving them attached is the
// single most common source of "my worker pool leaks memory".
const handler = (e: MessageEvent) => {
if (e.data.id === task.id) {
worker.removeEventListener('message', handler);
this.recycleWorker(worker);
resolve(e.data.result);
}
};
worker.addEventListener('message', handler);
worker.addEventListener('error', (err) => {
worker.removeEventListener('message', handler);
this.recycleWorker(worker);
reject(err);
}, { once: true });
worker.postMessage({ id: task.id, payload: task.payload });
}
private recycleWorker(worker: Worker) {
// Cancel any existing idle timer
const existing = this.idleTimers.get(worker);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
worker.terminate();
this.activeWorkers.delete(worker);
this.idleTimers.delete(worker);
const poolIdx = this.pool.indexOf(worker);
if (poolIdx !== -1) this.pool.splice(poolIdx, 1);
}, this.idleTimeout);
this.idleTimers.set(worker, timer);
this.pool.push(worker);
this.processQueue();
}
private processQueue() {
while (this.pool.length > 0 && this.taskQueue.length > 0) {
const worker = this.pool.pop()!;
const task = this.taskQueue.shift()!;
this.routeTask(worker, task, task.resolve, task.reject);
}
}
destroy() {
this.pool.forEach(w => w.terminate());
this.activeWorkers.forEach(w => w.terminate());
this.pool = [];
this.activeWorkers.clear();
this.idleTimers.forEach(t => clearTimeout(t));
this.idleTimers.clear();
// Reject parked tasks so callers never await a Promise that cannot settle.
this.taskQueue.forEach(t => t.reject(new Error('Pool destroyed')));
this.taskQueue = [];
}
}
Priority scheduling
Background threads need deterministic execution order. Naive postMessage calls create an unbounded FIFO in which a user-visible task queues behind a hundred background ones. A priority scheduler in front of the pool fixes that: critical work dispatches before background maintenance, and a fixed-timestep lane keeps simulation and physics consistent regardless of load.
Promise-based orchestration hides the message plumbing. Each dispatched task returns a Promise that settles when the worker replies, and rejections propagate back to the main thread for centralised handling.
// main-thread.js
export class PriorityTaskScheduler {
constructor(workerPool, maxConcurrency = 4) {
this.pool = workerPool;
this.maxConcurrency = maxConcurrency;
this.queues = { high: [], normal: [], low: [] };
this.activeCount = 0;
}
enqueue(task, priority = 'normal') {
if (!this.queues[priority]) throw new Error(`Unknown priority: ${priority}`);
this.queues[priority].push(task);
this.drain();
}
drain() {
while (this.activeCount < this.maxConcurrency) {
// Strict priority: a non-empty high queue always wins. Add ageing here if
// low-priority work must not starve indefinitely.
const task =
this.queues.high.shift() ||
this.queues.normal.shift() ||
this.queues.low.shift();
if (!task) break;
this.activeCount++;
this.execute(task).finally(() => {
this.activeCount--;
this.drain();
});
}
}
async execute(task) {
try {
const result = await this.pool.dispatch(task);
task.resolve(result);
} catch (err) {
task.reject(err);
}
}
}
Backpressure and the drain protocol
An unbounded queue is a memory leak with good manners. When producers outrun consumers — a websocket firing 500 messages a second into a pool of four workers — depth grows without limit until the tab is killed. Bound the queue explicitly and decide, in code, what happens when it is full: reject the newest task, drop the oldest, or coalesce duplicates by key.
A drain protocol makes shutdown and navigation safe. Stop accepting new tasks, wait for in-flight work to settle with a timeout, flush any partial results, then terminate. Without it, a single-page-app route change leaves workers computing results nobody will ever read — the exact scenario covered in Handling Worker Termination Gracefully in SPAs.
Cancellation deserves the same rigour. Because a worker cannot be interrupted mid-loop, long tasks must check a cancellation flag between chunks — either a shared Int32Array flag read with Atomics.load, or a chunked loop that yields between batches and checks for a cancel message. Fire-and-forget dispatch with no cancellation path is what turns a fast pool into a permanently backlogged one.
Production Checklist
- Avoid structured cloning for payloads exceeding 1MB. Main-thread blocking scales linearly with object graph depth. Transfer ownership instead to protect the frame budget.
- Pre-allocate and pool
ArrayBufferinstances. High-frequency pipelines benefit most: reusing buffers across invocations amortises allocation and removes the GC spikes that show up as periodic jank. - Cap active workers at a clamped
navigator.hardwareConcurrency. Exceeding physical core count adds OS context switching without throughput. - Always pass a transfer list for binary data. Omitting it silently falls back to an expensive structured clone — one of the easiest performance bugs to ship unnoticed.
- Recycle idle workers on a timeout. Threads hold resident memory even when doing nothing; recycling balances cold-start latency against footprint.
- Bound every queue and define an overflow policy. Unbounded task queues fail as out-of-memory crashes, not as slow responses.
- Monitor thread contention with
PerformanceObserver. Tracklongtaskentries on the main thread to detect when messaging, not computation, has become the bottleneck — postMessage Bottleneck Analysis covers the workflow. - Compile Wasm once per worker.
WebAssembly.Instanceobjects are not transferable; hold the instance as module-level state and reuse it across tasks. - Feature-detect
OffscreenCanvaswithtypeof OffscreenCanvas !== 'undefined'and keep a main-thread fallback. Safari added full support in 16.4, but older installs remain in the field. - Flatten errors before sending them. Serialise
message,stack,name, and any custom fields into a plain object so the telemetry pipeline receives something useful. - Re-measure after every change. A worker refactor that removes 200ms of blocking can silently reintroduce it the moment someone adds a
structuredCloneto the reply path.
Newer Ground in This Section
Two additions address the two ways an offloaded workload still ends up feeling slow.
Streaming & Backpressure Across Threads covers what happens when a worker produces faster than the page consumes. postMessage applies no backpressure at all, so the queue grows inside the browser where no heap snapshot can see it, and a long job ends in a tab that dies with memory nobody can attribute. Transferable streams solve it at the platform level; a credit protocol solves it where the traffic is not stream-shaped; and queue depth is the metric that tells you which of the two is failing.
Task Scheduling & Prioritization covers the other half: work that is on the right thread but in the wrong order. It sets out the main-thread yielding primitives for DOM work that cannot be offloaded, a priority queue with ageing for the dispatch side of a pool, interruptible worker tasks that can actually observe a cancellation, and the measurements — Interaction to Next Paint, queue wait time, long animation frames — that show whether the ordering changed anything.