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 main thread, the message boundary, and the five off-thread hosts An architecture map. A main-thread band at the top handles layout, input and paint and must stay under 4 milliseconds per task. Below it a dashed postMessage boundary separates it from five host contexts: data parsing, image processing, WebAssembly, OffscreenCanvas and service worker. Each host is joined to the main thread by a two-way arrow crossing the boundary, and each names what it sends across. A footer states that everything crosses by value, by transfer or by shared memory, and that nothing is shared implicitly. Main thread layout · input · paint — every task here must fit inside the frame budget postMessage boundary no DOM, no window Data parsing JSON · CSV · binary sends chunk buffers Image processing ImageData · filters sends pixel buffers WebAssembly SIMD · Rust · C sends the module OffscreenCanvas charts · animation sends the canvas Service worker cache · precompute answers requests Everything crosses by value, by transfer, or by shared memory nothing is shared implicitly — no window, no document, no closures over UI state
Each host owns its own heap and event loop; the two-way arrows are the only route in or out. Which of the three crossings you pick is the single decision this guide keeps returning to.

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.

Decision tree for choosing an execution context A flowchart. A candidate task is measured; if its p95 duration is under 4 milliseconds it stays on the main thread. If it exceeds 4 milliseconds and needs the DOM, it is chunked on the main thread with scheduler.yield. If it does not need the DOM, a second question asks whether the payload is binary or larger than 50 kilobytes: yes leads to a transfer list or SharedArrayBuffer, no leads to a structured clone. Both routes then feed a row of host contexts: dedicated worker, OffscreenCanvas worker, Wasm in a worker, and service worker. Candidate task p95 over 30 runs p95 over 4 ms? Keep on the main thread messaging would cost more No Yes Needs the DOM? Chunk on the main thread await scheduler.yield() Yes No Binary or over 50 KB? Structured clone one copy each way No Transfer list or SAB ownership moves, no copy Yes Then pick the host that owns the work Dedicated worker OffscreenCanvas worker Wasm in a worker Service worker
Two questions decide the context, a third decides the transfer strategy. Only after both are answered does the choice of host — dedicated, OffscreenCanvas, Wasm or service worker — actually matter.

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.

Frame timeline: a blocking transform versus the same work in a worker Two timelines over a 72 millisecond span marked with 16 millisecond frame boundaries. The first shows a single 45 millisecond transform occupying the main thread and running past three frame deadlines. The second shows the same 45 millisecond computation on a worker track, while the main thread carries only two sub-millisecond message handlers and stays free for paint and input. Without a worker — one 45 ms transform dashed rules = 16 ms frame boundaries 45 ms blocking transform idle main thread 3 frame deadlines missed — input and paint stall With a worker — same 45 ms, off the main thread main thread free for paint and input main thread postMessage in reply handler 45 ms compute in the worker worker 0 ms 16 ms 32 ms 48 ms 64 ms
The total work is identical in both rows. What changes is who is blocked: three missed frame deadlines become two sub-millisecond message handlers.

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.

One request and response round trip across the worker boundary A sequence diagram with two lifelines. The main thread posts a request with the buffer in the transfer list, which immediately detaches the caller's buffer so its byteLength becomes zero. The worker runs a histogram pass of roughly 38 milliseconds, then posts the result back with its own buffer transferred. The main thread deletes the pending entry keyed by message id and resolves the promise, while the worker instance stays warm for the next task. Main thread ComputeClient Worker realm compute.worker.js postMessage(req, [buffer]) buffer.byteLength is 0 histogram pass about 38 ms compute postMessage(res, [bins.buffer]) result transferred back, still zero-copy Promise settles entry deleted, then resolved Worker stays warm same instance, no respawn
Both legs of the round trip carry a transfer list, so neither payload is ever copied. The only main-thread work is the two message handlers at the ends of the trip.

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
Security

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.

Structured clone, transfer list and SharedArrayBuffer compared A comparison grid. Structured clone copies O(n) bytes twice and creates high garbage pressure, but the source stays usable and no headers are needed. A transfer list swaps a pointer in constant time with no garbage, at the price of detaching the source. SharedArrayBuffer makes no copy at all, keeps both sides usable and is the only option offering concurrent access through Atomics, but it requires COOP and COEP headers. Criterion Structured clone the default Transfer list ownership moves SharedArrayBuffer shared memory Copy cost O(n) bytes, twice O(1) pointer swap no copy at all GC pressure a full copy to collect none none Source usable after send yes, you keep a copy no, it detaches yes, both sides Concurrent access one owner at a time one owner at a time yes, with Atomics Header requirements none none COOP + COEP cheap, prefer it a caveat to design around the cost centre to avoid
Every performance question about the boundary reduces to this grid: which column you picked, and whether its caveat is acceptable for the payload in hand.

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.

Main-thread blocking time against payload size for three strategies A log-log chart. Payload size runs from 10 kilobytes to 10 megabytes on the horizontal axis; main-thread blocking time from 0.3 to 300 milliseconds on the vertical axis. Without a worker, blocking time tracks the payload and reaches about 170 milliseconds at 10 megabytes. A worker fed by structured clone flattens the curve but still charges the clone to the main thread, reaching about 30 milliseconds. A worker fed by a transfer list stays flat at roughly half a millisecond across the whole range. All three are indistinguishable below the 4 millisecond frame threshold, which the no-worker curve crosses at about 200 kilobytes. 4 ms main thread crosses 4 ms at roughly 200 KB 0.3 1 3 10 30 100 300 main-thread blocking (ms) 10 KB 50 KB 200 KB 1 MB 4 MB 10 MB no worker worker + structured clone worker + transfer list 4 ms frame threshold
Below roughly 200KB the three strategies are indistinguishable and the messaging overhead dominates. Above it, only the choice of transfer strategy keeps the main thread flat.

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
Engine behaviour

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.

Browser support grid for the worker APIs used on this page A support grid of eight APIs across Chrome, Firefox, Safari and Edge. Dedicated workers and transferable ArrayBuffers are universal. Module workers, ImageBitmap transfer, Atomics.waitAsync and Wasm SIMD are gated on a minimum version. OffscreenCanvas is gated everywhere and needs a main-thread fallback on Safari before 16.4. SharedArrayBuffer is marked as requiring cross-origin isolation headers in every engine. API Chrome Firefox Safari Edge Dedicated Worker yes yes yes yes Module workers (type: module) 80+ 114+ 15+ 80+ Transferable ArrayBuffer yes yes yes yes ImageBitmap transfer 50+ 42+ 15+ 79+ OffscreenCanvas (2D and WebGL) 69+ 105+ 16.4+ 79+ SharedArrayBuffer and Atomics 68+ 79+ 15.2+ 79+ Atomics.waitAsync 87+ 127+ 16.4+ 87+ Wasm SIMD 91+ 89+ 16.4+ 91+ universal from this version needs isolation headers or a fallback
Two rows dictate deployment: 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.

Performance

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.

Five workload families and the transfer strategy each one turns on Five lanes, each showing an input, a transfer strategy and a result. Structured data goes from an 8 megabyte JSON text through encoded transferable chunks to typed rows reassembled per chunk. Pixels go from a 12 megapixel ImageData through an in-place buffer transfer to filtered pixels with zero copies. Compiled code goes from a WebAssembly module cloned to each worker, instantiated locally, to a typed-array result with no JIT warm-up. Frames go from a canvas element through transferControlToOffscreen to frames painted without the main thread. Network-adjacent work goes from an intercepted fetch request through the Cache API and waitUntil to a stale-while-revalidate result. WORKLOAD INPUT TRANSFER STRATEGY RESULT Structured data JSON · CSV · binary 8 MB JSON text one string, one big clone TextEncoder to bytes transfer each chunk buffer typed rows reassembled per chunk Pixels filters and kernels 12 MP ImageData already an ArrayBuffer transfer the buffer mutate in place, send back filtered pixels zero copies made Compiled code Rust · C · C++ WebAssembly.Module compiled once, cloned instantiate per worker linear memory stays local typed-array result no JIT warm-up Frames canvas and charts canvas element transferControlToOffscreen() OffscreenCanvas render loop runs off-thread painted frames main thread never draws Network-adjacent service worker fetch request intercepted before the page Cache API + waitUntil must survive a restart cached result stale-while-revalidate
Each family is defined by what crosses the boundary, not by the algorithm inside the worker. Read the middle column first: it is the decision the guide for that family spends most of its time on.

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.

A bounded priority queue draining into a fixed-size worker pool Producers feed a bounded queue capped at 64 tasks, split into high, normal and low priority lanes. A pool of four workers drains the queue and returns one promise per task id. When the queue is full an overflow policy decides whether to reject the newest task, drop the oldest, or coalesce by key. On shutdown a drain protocol stops accepting work, awaits in-flight tasks, flushes, then terminates and rejects anything still parked. A dashed feedback path applies backpressure to the producers. backpressure: pause or shed producers Producers websocket, uploads user actions Bounded queue · cap 64 high 3 waiting normal 18 waiting low 41 waiting Worker pool · 4 worker 1 · busy worker 2 · busy worker 3 · busy worker 4 · idle Results one promise per task id When it is full reject newest · drop oldest or coalesce by key Drain on shutdown stop accepting, await in-flight, flush then terminate() and reject what is parked
The queue's cap, its overflow policy and its drain path are three separate decisions. Leaving any of them implicit 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 ArrayBuffer instances. 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. Track longtask entries 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.Instance objects are not transferable; hold the instance as module-level state and reuse it across tasks.
  • Feature-detect OffscreenCanvas with typeof 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 structuredClone to 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.

Frequently Asked Questions

When should I offload computation to a Web Worker?
Offload any task that consistently takes longer than 4–5ms on the main thread. Use performance.now() around the synchronous call; if it exceeds your frame budget on mid-tier devices, move it to a worker. Good candidates include JSON parsing >1.5MB, image convolution, CSV transforms, and WebAssembly module execution.
How do I choose between structured clone, transferable objects, and SharedArrayBuffer?
Use structured clone for small objects (<50KB) where simplicity matters. Use transferable ArrayBuffer for large binary payloads where one thread hands off ownership. Reserve SharedArrayBuffer with Atomics for low-latency concurrent access between multiple workers — it requires COOP/COEP headers and adds coordination complexity.
What is the right worker pool size for CPU-bound tasks?
Start at navigator.hardwareConcurrency. Add at most one overflow worker during sustained spikes. Beyond physical core count, OS scheduling overhead negates throughput gains. For I/O-bound work the equation differs, but pure compute tasks benefit most from matching physical cores.
Can OffscreenCanvas replace all main-thread canvas rendering?
For deterministic animation loops and filter pipelines, yes — transfer control with canvas.transferControlToOffscreen() and drive the render loop from the worker. Legacy Safari (pre-16.4) lacks full OffscreenCanvas support, so always check typeof OffscreenCanvas !== 'undefined' and keep a main-thread fallback.
Is WebAssembly always faster than JavaScript in a worker?
Not always. V8’s JIT compiler closes the gap for simple numeric loops. Wasm shines for code with predictable memory layouts, SIMD operations, or when porting existing C/C++/Rust algorithms. Measure both paths with realistic payloads before committing to a Wasm build pipeline.
Why does my worker make the page slower instead of faster?
Almost always because the payload is being copied twice. A structured clone on the way in and another on the way out can cost more than the computation itself. Measure the clone with performance.now() on both sides, then switch to a transferable ArrayBuffer — or keep the task on the main thread if it finishes in under 4ms.

See also