Image Processing in Workers

Pixel manipulation is the archetypal main-thread offender: a single loop over a full-screen image touches millions of array elements, cannot yield, and holds the event loop hostage until it finishes. This guide is a specialisation of High-Performance Computation Patterns, applied to raster work — filters, convolution kernels, colour-space conversion, resizing, and image encoding — and it covers the whole route: getting pixels out of a canvas, into a worker without copying, through a filter chain, and back onto the screen at 60fps.

The Frame Budget Problem

A 1920×1080 image is 2,073,600 pixels, or 8,294,400 bytes of RGBA data. A trivial grayscale pass touches every one of those bytes; a 3×3 convolution reads nine neighbours per channel, which works out to roughly 56 million multiply-accumulate operations for one pass over one 1080p frame. At 4K (3840×2160) both numbers quadruple: 33.2 MB of pixels and around 224 million operations.

On the main thread the symptom is unambiguous. The user drags a “blur radius” slider, the input handler runs the kernel synchronously, and the browser cannot paint, cannot run requestAnimationFrame, and cannot dispatch the next pointermove until the loop returns. A 60fps budget is 16.7 ms per frame; a single 4K blur pass measured on a mid-range laptop takes long enough to swallow several frames in a row, which the Performance panel renders as one fat yellow “Long Task” block with a red corner and the tooltip Forced reflow / long task. The slider stops tracking the cursor, and the whole UI feels broken even though the arithmetic is perfectly correct.

Frame budget: one long blur task on the main thread versus the same blur on a worker Two stacked timelines, each divided into ten 16.7 millisecond frame slots. In the upper timeline the main thread runs a 4K blur as a single 96 millisecond task that spans six consecutive slots; those six slots are marked with crosses as dropped frames. In the lower timeline the main thread shows only a short transfer and putImageData sliver inside every slot, all ten marked as met, while a parallel worker track carries the identical 96 milliseconds of blur off the critical path. Blur runs on the main thread 16.7 ms one 60 fps frame slot Main blur(radius 8) — 96 ms, cannot yield six frames dropped — the slider stops tracking the cursor Blur runs on a worker Main main thread: transfer + putImageData ≈ 0.4 ms per frame Worker the same 96 ms of blur, off the critical path every frame slot met — zero dropped frames
The same 96 ms of blur, scheduled two ways. On the main thread it is one unyieldable task that eats six consecutive frame slots; on a worker the main thread only pays for a transfer and a putImageData in each slot.

The fix is structural rather than algorithmic. Move the loop to a thread that has no paint responsibilities, and make sure the pixels get there without being copied.

The Round-Trip Architecture

A worker runs in an isolated scope with no window, no document, and no access to the DOM. That restriction is exactly what makes it safe for image work: nothing inside the worker can trigger layout or paint, so the browser is free to schedule it on another core while the compositor keeps running. Communication happens only through structured messages, which is why the interesting engineering in this topic is about the shape of the payload rather than the filter maths.

The main-thread half of the pattern is small. Spawn the worker once, keep it alive across interactions, and treat every message as one complete unit of work:

// main-thread.ts
type FilterSpec = { name: string; params?: Record<string, number> };

interface WorkerResult {
  type: 'PIPELINE_DONE' | 'PIPELINE_FAILED';
  buffer?: ArrayBuffer;
  width?: number;
  height?: number;
  error?: { name: string; message: string; stack?: string };
}

// A module worker keeps `import` available inside the worker scope.
const worker = new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' });

worker.addEventListener('message', (e: MessageEvent<WorkerResult>) => {
  if (e.data.type === 'PIPELINE_FAILED') {
    // Errors do NOT structured-clone usefully; see the failure-modes section below.
    console.error('Filter pipeline failed:', e.data.error?.message);
    return;
  }
  const { buffer, width, height } = e.data;
  ctx.putImageData(new ImageData(new Uint8ClampedArray(buffer!), width!, height!), 0, 0);
});

// Fires for script load failures and uncaught throws that escape the worker's own handler.
worker.addEventListener('error', (err) => {
  console.error('Worker thread fault:', err.message, err.filename, err.lineno);
});

Reserve a worker for work that consistently exceeds a frame budget. Spawning one costs roughly 1–5 ms (script fetch, parse, scope construction), and each message hop adds a fraction of a millisecond, so dispatching one message per mouse-move for a 2 ms operation makes things slower, not faster. Batch instead: coalesce slider input with requestAnimationFrame and send at most one job per frame, dropping any job still queued behind it.

Image processing round-trip between main thread and Web Worker A left-to-right flow diagram showing: Canvas on the main thread calls getImageData to produce an ArrayBuffer, which is transferred via postMessage to a Web Worker. Inside the worker a filter pipeline runs grayscale, invert, and threshold in sequence. The result ArrayBuffer is transferred back via postMessage to the main thread, where new ImageData is constructed and putImageData renders it back to the Canvas. Main Thread Canvas getImageData ArrayBuffer postMessage [transfer] Web Worker grayscale invert threshold postMessage [transfer] Main Thread ArrayBuffer new ImageData Canvas putImageData
Round-trip flow for zero-copy image processing: the main thread transfers an ArrayBuffer to the worker, the filter pipeline mutates pixels in place, and the result is transferred back for rendering.

Prerequisites

Before implementing the pipeline below, confirm the following:

  • A canvas you control. getImageData() on a canvas that has ever drawn a cross-origin image without CORS throws a SecurityError — the canvas is tainted. Load source images with crossorigin="anonymous" and serve them with Access-Control-Allow-Origin.
  • A same-origin (or CORS-enabled) worker script URL. The Worker constructor rejects cross-origin scripts outright; a cross-origin script fetched through a Blob URL loses useful error reporting.
  • Module worker support if you use { type: 'module' } — Chrome 80+, Firefox 114+, Safari 15+. Below that, bundle the worker to a classic script. Bundling Module Workers with Vite and webpack covers the build configuration for both.
  • A feature test for OffscreenCanvas if you plan to render or encode inside the worker, plus a main-thread fallback path.
  • Cross-origin isolationCross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp — only if you intend to parallelise across multiple workers with SharedArrayBuffer & Atomics. The single-worker transfer path needs no special headers.
  • A baseline measurement. Record the current main-thread cost with performance.now() before you start, or you will have no way to prove the rewrite helped.

Step-by-Step Implementation

Step 1 — Hand pixel ownership to the worker

getImageData() returns an ImageData whose data is a Uint8ClampedArray over an ArrayBuffer. That ArrayBuffer is transferable: putting it in the second argument of postMessage moves the allocation to the worker instead of copying it. The mechanics generalise to every large binary payload and are covered in depth in Transferable Objects & Zero-Copy.

// main-thread.ts
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { width, height } = imageData;      // capture BEFORE transfer — dimensions are not in the buffer
const buffer = imageData.data.buffer;

worker.postMessage(
  { type: 'RUN_PIPELINE', buffer, width, height, filters: [{ name: 'grayscale' }] },
  [buffer]                                 // transfer list: ownership moves, nothing is copied
);

// imageData.data.byteLength === 0 from here on. Drop the reference so the
// detached wrapper object is collectable.
Trade-off: detachment is immediate and irreversible

After transfer, the main thread's ImageData is a hollow shell — data.byteLength is 0 and every read returns nothing. If you need the original pixels for an undo stack or a side-by-side preview, copy them before transferring (imageData.data.slice() costs one full 8.3 MB memcpy, roughly 2–4 ms at 1080p) or ask the worker to return both the source and the result.

Step 2 — Structure the worker as a filter registry

Keep every filter a pure function over (data, width, height, params) that mutates the buffer in place. A registry keyed by name lets the main thread describe a pipeline declaratively, and lets you run the entire chain in one message handler — one pass over memory, no intermediate allocations, no extra hops.

// image-worker.js
const filters = {
  grayscale(data) {
    for (let i = 0; i < data.length; i += 4) {
      // Rec. 601 luma weights — matches how the eye weights the channels.
      const luma = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
      data[i] = data[i + 1] = data[i + 2] = luma;  // Uint8ClampedArray rounds and clamps for us
    }
  },
  invert(data) {
    for (let i = 0; i < data.length; i += 4) {
      data[i] = 255 - data[i];
      data[i + 1] = 255 - data[i + 1];
      data[i + 2] = 255 - data[i + 2];             // alpha at i+3 deliberately untouched
    }
  },
  threshold(data, _w, _h, { cutoff = 128 } = {}) {
    for (let i = 0; i < data.length; i += 4) {
      const v = data[i] > cutoff ? 255 : 0;
      data[i] = data[i + 1] = data[i + 2] = v;
    }
  }
};

self.onmessage = (e) => {
  const { buffer, width, height, filters: chain } = e.data;
  const pixels = new Uint8ClampedArray(buffer);

  try {
    for (const step of chain) {
      const fn = filters[step.name];
      if (!fn) throw new RangeError(`Unknown filter: ${step.name}`);
      fn(pixels, width, height, step.params);
    }
  } catch (err) {
    // Error objects clone unreliably across engines — flatten to plain data.
    self.postMessage({
      type: 'PIPELINE_FAILED',
      error: { name: err.name, message: err.message, stack: err.stack }
    });
    return;
  }

  self.postMessage({ type: 'PIPELINE_DONE', buffer, width, height }, [buffer]);
};
Trade-off: one pass vs one message per filter

Chaining three filters in a single handler reads the 8.3 MB working set once per filter but never leaves the worker. Sending the buffer back between filters would add two full round trips and force the main thread to re-enter putImageData mid-chain. Keep the chain inside the worker; only pay for a round trip when the user actually needs to see an intermediate result.

Notice that the worker does not call self.close(). Terminating after every job throws away a warm JIT and forces a fresh script parse on the next interaction. Keep the worker resident for the lifetime of the editing session, and terminate it only when the view unmounts.

Step 3 — Convolution, the pass that actually needs a worker

Point filters like grayscale are memory-bandwidth-bound and finish in a few milliseconds. Convolution is where the frame budget dies, because each output pixel reads a neighbourhood. Two optimisations matter more than anything else: read from a scratch copy so the kernel never sees pixels it has already written, and exploit separability — a Gaussian blur of radius r can be run as a horizontal pass followed by a vertical pass, turning (2r+1)² taps per pixel into 2(2r+1).

// image-worker.js — separable box/Gaussian blur, one axis at a time
function blurAxis(src, dst, width, height, radius, horizontal) {
  const norm = 1 / (radius * 2 + 1);
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      let r = 0, g = 0, b = 0;
      for (let k = -radius; k <= radius; k++) {
        // Clamp at the edges instead of wrapping, or the blur bleeds across the image.
        const sx = horizontal ? Math.min(width - 1, Math.max(0, x + k)) : x;
        const sy = horizontal ? y : Math.min(height - 1, Math.max(0, y + k));
        const si = (sy * width + sx) * 4;
        r += src[si]; g += src[si + 1]; b += src[si + 2];
      }
      const di = (y * width + x) * 4;
      dst[di] = r * norm; dst[di + 1] = g * norm; dst[di + 2] = b * norm;
      dst[di + 3] = src[di + 3];               // preserve alpha
    }
  }
}

filters.blur = (data, width, height, { radius = 4 } = {}) => {
  const scratch = new Uint8ClampedArray(data);   // one allocation, reused across both passes
  blurAxis(data, scratch, width, height, radius, true);   // horizontal
  blurAxis(scratch, data, width, height, radius, false);  // vertical, back into the original
};
Trade-off: separability beats micro-optimisation

At radius 8, the naive 2D kernel is 289 taps per pixel; the separable version is 34 — an order-of-magnitude difference that no amount of loop tuning recovers. The cost is one extra full-size scratch buffer (8.3 MB at 1080p) and two passes over memory instead of one. Non-separable kernels (Sobel edge detection, arbitrary 3×3 emboss) do not get this discount; when they dominate your profile, port the inner loop to SIMD in worker threads, where 128-bit lanes process four channels at once.

Kernel cost: 289 taps per pixel versus 34 taps per pixel at radius 8 On the left, a seventeen by seventeen grid of cells represents the full two-dimensional window a naive radius-8 blur reads for a single output pixel, highlighted at the centre of the grid: 289 taps per pixel, about 599 million multiply-adds for one 1080p frame. On the right, the same blur is decomposed into a horizontal strip of seventeen cells whose result is written into a scratch buffer, then a vertical strip of seventeen cells read back out of it: 34 taps per pixel, about 70 million multiply-adds per frame, roughly eight and a half times fewer reads. Naïve 2-D kernel (2r+1)² = 17 × 17 = 289 taps ≈ 599 M multiply-adds per 1080p frame the one output pixel those 289 reads produce Separable: two 1-D passes horizontal pass 17 taps scratch buffer vertical pass 17 taps 2(2r+1) = 17 + 17 = 34 taps ≈ 70 M multiply-adds per 1080p frame 8.5× fewer reads for the same blur
Separability, drawn to scale: the naïve window reads all 289 cells for one output pixel, while the two 1-D passes read 17 each and meet in a scratch buffer.

Step 4 — Tile the image so progress is reportable

A single 4K pass can still run for a noticeable stretch inside the worker. Nothing on screen freezes, but the user gets no feedback and you cannot cancel. Splitting the image into horizontal bands lets the worker post progress after each band and check a cancellation flag between them.

// image-worker.js
const TILE_ROWS = 128;
let currentJob = 0;

self.addEventListener('message', (e) => {
  if (e.data.type === 'CANCEL') { currentJob++; return; }  // invalidate the in-flight job
  if (e.data.type !== 'RUN_TILED') return;

  const job = ++currentJob;
  const { buffer, width, height, radius } = e.data;
  const pixels = new Uint8ClampedArray(buffer);

  for (let top = 0; top < height; top += TILE_ROWS) {
    if (job !== currentJob) return;                        // a newer job arrived; abandon this one
    const rows = Math.min(TILE_ROWS, height - top);
    // Halo: kernels read `radius` rows outside the band, so widen the read window.
    blurBand(pixels, width, height, top, rows, radius);
    self.postMessage({ type: 'PROGRESS', done: top + rows, total: height });
  }

  self.postMessage({ type: 'PIPELINE_DONE', buffer, width, height }, [buffer]);
});
Trade-off: halo rows are not optional

Any kernel with a radius reads pixels above and below its band. If each band is filtered independently without a halo of radius overlapping rows, you get visible seams at every tile boundary. The overlap costs extra reads proportional to height / TILE_ROWS × radius, so keep tiles reasonably tall — 128 rows is a good starting point at 1080p. The same banding logic is what lets a worker pool split one image across navigator.hardwareConcurrency - 1 threads.

Halo rows around a tiled band, and the seams left when they are omitted On the left, an image divided into four horizontal bands. Band two is highlighted as the band being written, and hatched strips of halo rows extend a kernel radius above and below it into the neighbouring bands; those rows are read but never written. On the right, the same image filtered without halo rows shows two hard horizontal seams where the tone jumps at each tile boundary, because every band was blurred using only its own rows. With halo rows band 2 · 128 rows written band 1 band 3 band 4 halo = radius rows read, never written the worker posts PROGRESS after each band Without halo rows visible seams at every tile boundary each band blurred from its own rows only
Each band writes only its own rows but must read a halo of radius rows from its neighbours. Drop the halo and the tone jumps at every boundary.

Step 5 — Encode to WebP inside the worker

Encoding is CPU-heavy and, unlike filtering, has a native implementation the worker can call directly. OffscreenCanvas.convertToBlob() runs the browser’s own encoder off the main thread and returns a Blob you can upload straight away.

// encode-worker.js
self.onmessage = async (e) => {
  const { pixels, width, height, quality } = e.data;

  const surface = new OffscreenCanvas(width, height);
  const ctx = surface.getContext('2d');
  ctx.putImageData(new ImageData(new Uint8ClampedArray(pixels), width, height), 0, 0);

  // NOTE: an unsupported `type` silently falls back to image/png per spec —
  // read blob.type back rather than assuming you got what you asked for.
  const blob = await surface.convertToBlob({ type: 'image/webp', quality });
  self.postMessage({ type: 'ENCODED', blob, actualType: blob.type });
};
Trade-off: native encoders vs shipping your own

convertToBlob() costs nothing to ship and encodes at native speed, but you only get the formats the browser implements — in practice PNG, JPEG, and WebP. No browser exposes an AVIF or JPEG XL encoder through canvas, so a request for image/avif comes back as a PNG with no error. If you need those formats, compile an encoder to WebAssembly and accept the cold-start cost (roughly 50–150 ms for compile plus instantiation); pre-warm it during idle time so the first user-visible encode does not pay for it.

Blobs are cloneable but not transferable — the browser passes a reference to the underlying byte store rather than serialising the bytes, so returning a 2 MB WebP Blob is cheap. If you are caching encoded output for reuse, Caching Computed Results with the Cache API shows how to keep the result out of memory entirely.

Step 6 — Let the worker own the surface with OffscreenCanvas

Everything above keeps the canvas on the main thread and ships pixels back and forth. When the worker is producing every frame — a live camera filter, a paint tool, an animated visualisation — hand it the surface instead and stop round-tripping altogether.

// main-thread.ts
const canvas = document.getElementById('render-target') as HTMLCanvasElement;

if (typeof OffscreenCanvas === 'undefined' || !canvas.transferControlToOffscreen) {
  mountMainThreadFallback(canvas);            // 2D context on the main thread
} else {
  const offscreen = canvas.transferControlToOffscreen();
  // The surface is transferable; after this call the main thread can never draw to it again.
  worker.postMessage({ type: 'INIT_RENDER', canvas: offscreen }, [offscreen]);
}

Inside the worker, drive the loop with the worker scope’s own requestAnimationFrame — it is throttled to the display refresh exactly like the main thread’s, so there is no need to ping-pong “next frame” messages:

// canvas-worker.js
let ctx = null;
self.onmessage = (e) => {
  if (e.data.type !== 'INIT_RENDER') return;
  ctx = e.data.canvas.getContext('2d');
  const draw = () => { renderFrame(ctx); self.requestAnimationFrame(draw); };
  self.requestAnimationFrame(draw);
};
Trade-off: transferring control is one-way

transferControlToOffscreen() throws an InvalidStateError if the canvas already has a context, and once it succeeds the main thread cannot draw to that element again — including any library that expects to grab a 2D context later. Decide ownership at mount time, never mid-session. The full lifecycle, resize handling, and fallback strategy live in OffscreenCanvas Rendering and Transferring Canvas Control to a Worker.

Data-Transfer Strategy: Clone, Transfer, Share or ImageBitmap

Image work offers four distinct ways to get pixels across the thread boundary, and picking the wrong one is the most common reason a “worker-optimised” pipeline ends up slower than the synchronous version it replaced.

Strategy Cost for 8.3 MB (1080p RGBA) Source usable after? Choose when
Structured clone (postMessage(imageData)) Full copy, ~10–20 ms per hop, peak memory doubles Yes Small thumbnails, or you genuinely need the original and the copy
Transfer (postMessage(buffer, [buffer])) Constant time, independent of size No — detached Default for one-shot filter jobs and encode jobs
ImageBitmap (transferable) Constant time; decode already done off-thread No — closed The source is a Blob/<img>/video frame and the worker draws rather than reads pixels
SharedArrayBuffer Zero copy, no handoff at all Yes — concurrently Several workers filtering disjoint bands of one image, or a persistent frame buffer

Transfer is the right default. The buffer is produced by one thread, consumed by another, and never needed on both sides at once — which is precisely the ownership model transfer encodes. The measured difference is not subtle: transferring is a pointer handoff whose cost does not grow with the payload, while cloning is a memcpy plus allocation that scales linearly and blocks the sender for the duration.

ImageBitmap is the right choice when the worker draws rather than reads. createImageBitmap() works inside workers, decodes the source off the main thread, accepts resizeWidth/resizeHeight for a free downscale during decode, and produces a GPU-friendly object that drawImage() can blit without a pixel-by-pixel upload. If your pipeline is decode → resize → draw, never touch ImageData at all:

// thumbnail-worker.js
self.onmessage = async ({ data: { blob, size } }) => {
  const bitmap = await createImageBitmap(blob, {
    resizeWidth: size, resizeHeight: size, resizeQuality: 'high'
  });
  const surface = new OffscreenCanvas(size, size);
  surface.getContext('2d').drawImage(bitmap, 0, 0);
  bitmap.close();                                   // release GPU/decoded memory immediately
  const thumb = await surface.convertToBlob({ type: 'image/webp', quality: 0.8 });
  self.postMessage({ thumb });
};

SharedArrayBuffer earns its complexity only under parallelism. Because every thread sees the same bytes, four workers can filter four bands of one 4K frame with no handoff at all, coordinated by Atomics.add handing out tile indices. The price is real: the document must be cross-origin isolated with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, which also constrains every third-party asset you embed, and you must reason about visibility and ordering yourself. For a single filter job it is strictly worse than a transfer.

Four ways to move one 8.3 MB frame across the thread boundary Row one, structured clone: an 8.3 megabyte buffer on the main thread is memcpy'd into a second identical buffer in the worker, costing 10 to 20 milliseconds and peaking at two buffers, but the source stays readable. Row two, transfer: the main-thread buffer is drawn hollow and detached with byteLength zero while ownership of the same allocation moves to the worker in constant time. Row three, ImageBitmap: a Blob on the main thread is decoded off-thread into an ImageBitmap inside the worker scope and blitted to a canvas with drawImage, in constant time, with the bitmap closing on transfer. Row four, SharedArrayBuffer: three workers point into disjoint segments of one shared 8.3 megabyte allocation, coordinated by Atomics, with zero copying but a requirement for cross-origin isolation headers. Strategy What crosses the thread boundary Cost · source afterwards Structured clone only for thumbnails main thread 8.3 MB still readable memcpy 8.3 MB worker 8.3 MB copy peak: two buffers Full copy · 10–20 ms cost scales with size source stays usable Transfer the right default main thread detached byteLength 0 ownership moves worker 8.3 MB same allocation Constant time, any size no bytes are copied source detaches ImageBitmap when the worker draws main thread Blob decode worker scope ImageBitmap drawImage canvas bitmap.close() frees the decoded copy Constant time · GPU-side decode happens off-thread bitmap closes on transfer SharedArrayBuffer only under parallelism worker 1 worker 2 worker 3 8.3 MB, shared Atomics.add hands out disjoint row ranges Zero copy, no handoff needs COOP + COEP every thread reads it
The same 8.3 MB frame under four payload shapes. Only the first pays a cost proportional to the image; only the last leaves the pixels readable from more than one thread at a time.

Metadata is the exception to all of this. EXIF blocks, colour profiles, and pipeline descriptors are small structured objects; clone them normally alongside the transferred buffer. Only when the metadata itself becomes large — thousands of detection boxes, per-pixel annotations — is it worth the encoding work described in Data Parsing & Serialization.

Verification & Measurement

A worker rewrite is only a win if you can show the numbers. Measure the whole round trip, not just the filter:

  1. Record performance.now() immediately before the first postMessage, and again inside the message handler after ctx.putImageData() returns. The gap is what the user actually waits for.
  2. Have the worker report its own compute time in the result message. Round trip minus compute is your transfer and scheduling overhead; at 1080p with transfers it should stay well under a millisecond in each direction.
  3. Assert imageData.data.byteLength === 0 right after postMessage. A non-zero length proves the buffer was cloned, not transferred — usually because it was omitted from the transfer list.
  4. Open the Chrome Performance panel and confirm the compute block appears on a Worker track, not on Main. If the long task is still on Main, the pixels are being copied rather than moved, or the filter never left the main thread. Profiling Worker CPU Usage with the Chrome Performance Tab walks through reading that track.
  5. Check for dropped frames with the Frames track while dragging a filter control. The pass condition is that no frame exceeds 16.7 ms during continuous interaction.
  6. Verify output correctness with a fixed test image and a checksum over the result buffer, so a “faster” refactor cannot silently change the pixels.
Where the 43.7 ms of one filter round trip actually goes A four-row Gantt-style timeline measured from t0, taken immediately before the first postMessage, to t1, taken after putImageData returns. The outbound postMessage transfer takes 0.15 milliseconds, the worker's self-reported filter chain takes 42.2 milliseconds, the return transfer takes 0.16 milliseconds and putImageData takes 1.2 milliseconds, for a 43.7 millisecond round trip. A bracket under the compute row shows it accounts for 97 percent of the wait, leaving 1.5 milliseconds of transfer and paint overhead. Sub-millisecond spans are drawn at a minimum width so they remain visible. round trip the user waits: 43.7 ms postMessage out 0.15 ms worker compute filter chain · 42.2 ms (self-reported) postMessage back 0.16 ms putImageData 1.2 ms 0 10 20 30 40 ms t0 = performance.now() t1 = after putImageData() compute · 42.2 ms (97% of the wait) overhead 1.5 ms sub-millisecond spans drawn at a minimum width
Round trip minus self-reported compute is your transfer and scheduling overhead. If that gap grows with image size, the buffer is being cloned rather than transferred.

Failure Modes & Error Handling

Worker errors do not surface where main-thread errors do, and image pipelines add their own class of failure on top. Attach both handlers on the worker object and both global handlers inside the worker scope:

// image-worker.js — catch everything that would otherwise vanish
self.addEventListener('error', (e) => {
  self.postMessage({ type: 'PIPELINE_FAILED', error: { name: 'Error', message: e.message } });
});
self.addEventListener('unhandledrejection', (e) => {
  const r = e.reason;
  self.postMessage({
    type: 'PIPELINE_FAILED',
    error: { name: r?.name ?? 'UnhandledRejection', message: String(r?.message ?? r), stack: r?.stack }
  });
});

Error objects are cloneable in modern engines but their stack is not reliably preserved across all of them, so flatten to a plain { name, message, stack } object before posting — the pattern is generalised in Structured Error Serialization Across Threads. For retries, note that a transferred buffer is gone: a failed job cannot simply be re-sent, because the main thread no longer owns the pixels. Either re-read them from the canvas (getImageData again) or have the worker return the buffer on the failure path too.

Where an image-pipeline fault surfaces: in-band message versus out-of-band ErrorEvent Two parallel flows. The in-band path runs left to right: a throw or rejection inside a job, the worker catching it through try/catch plus global handlers, flattening it to a plain object with name, message and stack, posting it as PIPELINE_FAILED, and the main thread handling it in worker.onmessage. The out-of-band path runs beneath: the script fails to load or throws on parse, the browser fires an ErrorEvent carrying only message, filename and line number, and with nothing to serialise it arrives at worker.onerror. A closing note points out that either path leaves the main thread without pixels, because the buffer was already transferred away. In-band — the worker reports its own failure throw or rejection worker catches it flatten the error postMessage worker.onmessage inside a job try/catch + globals {name, message, stack} PIPELINE_FAILED show it, then recover Out-of-band — the fault never reaches your handler script fails to load browser fires no structured payload worker.onerror or throws on parse an ErrorEvent message · filename · lineno log it, then respawn nothing to serialise Either path leaves the main thread without pixels — the buffer was already transferred away. Re-read with getImageData(), or return the buffer on the failure path too.
Only the in-band path gives you a usable error object. The out-of-band path arrives as a bare ErrorEvent — and neither one gives the pixels back.
Symptom Likely cause Fix
imageData.data.byteLength === 0 after postMessage The buffer transferred correctly — this is expected Never read the source after transfer; rebuild from the returned buffer
Worker returns the original, unfiltered pixels The buffer was cloned, so the filter mutated a copy Confirm the ArrayBuffer is in the transfer list, not only in the payload
SecurityError from getImageData Canvas tainted by a cross-origin image Set crossorigin="anonymous" on the source and serve Access-Control-Allow-Origin
transferControlToOffscreen throws InvalidStateError The canvas already has a 2D or WebGL context Transfer before anything calls getContext; feature-test first
convertToBlob returns a PNG when WebP/AVIF was requested Format not supported by this browser’s encoder Read back blob.type; fall back explicitly rather than assuming
Visible seams between processed bands Tiles filtered without halo rows Extend each band’s read window by the kernel radius
Memory climbs across repeated jobs Detached buffers, ImageBitmaps, or object URLs retained Null references after transfer, call bitmap.close(), URL.revokeObjectURL()
Result arrives after the user has moved on No cancellation protocol Version each job and abandon stale ones, as in Step 4

Browser Compatibility

API Chrome Firefox Safari Edge
Web Workers 4 3.5 4 12
Transferable ArrayBuffer 17 18 6 12
Module workers (type: 'module') 80 114 15 80
createImageBitmap in workers 50 42 15 79
ImageBitmap (transferable) 52 42 15 79
OffscreenCanvas 69 105 16.4 79
OffscreenCanvas.convertToBlob() 76 105 16.4 79
SharedArrayBuffer (cross-origin isolated) 68 79 15.2 79
WebP encoding via canvas 50 96 14 79

The practical floor for the transfer-based pipeline is very low — transferable ArrayBuffer support has been universal for a decade. OffscreenCanvas is the gating feature for worker-side rendering and encoding, so keep the main-thread fallback until your analytics say Safari 16.3 and Firefox 104 are gone from your traffic.


Going Further

Decoding is usually the largest single block of main-thread time in an upload flow, and it is entirely avoidable. Resizing Images with createImageBitmap in Workers covers decoding and downscaling in one step on the worker thread, the options that control quality and orientation, bitmap transfer, and the memory ceiling that decides how large a pool can safely be.

Frequently Asked Questions

Why use transferable objects instead of structured clone for ImageData?
Structured-cloning an 8.3 MB ImageData buffer (1920×1080 RGBA) copies every byte, blocks the sending thread for roughly 10–20 ms, and briefly doubles heap pressure. A transferable ArrayBuffer hands over ownership of the same allocation in constant time — the cost does not scale with buffer size. The trade-off is that the source reference detaches immediately, so nothing on the sending side may read it again.
Does OffscreenCanvas work in all browsers?
OffscreenCanvas landed in Chrome 69, Firefox 105, and Safari 16.4; Edge follows Chrome. Always check typeof OffscreenCanvas !== 'undefined' before calling transferControlToOffscreen() and keep a main-thread 2D-context fallback. convertToBlob() is available in the same versions, so a single feature test covers both the rendering and the encoding path.
How do I chain multiple filters without extra postMessage round-trips?
Run the whole pipeline inside one worker message handler, with each filter reading and writing the same Uint8ClampedArray in place. One pass keeps the working set hot in L2/L3 cache and removes all inter-message overhead. Only post the buffer back when the last filter has finished.
When should I use WebAssembly instead of JavaScript for the pixel loop?
Plain JS over typed arrays is already close to memory-bandwidth-bound for grayscale, invert, and threshold — WebAssembly buys little there. Reach for it when you need SIMD-width convolution, a real codec (JPEG XL, AVIF encode), or an existing C/C++ imaging library. See WebAssembly in Workers for instantiation and memory patterns.
Can several workers filter the same image at once?
Yes — split the image into horizontal bands and give each worker its own transferred slice, or back the image with a SharedArrayBuffer so every worker writes into disjoint rows of one allocation. The shared-memory route needs Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on the document, and Atomics to hand out tile indices. Kernels that read neighbouring pixels also need a halo of overlapping rows per band.

See also