Using Transferable Objects for Canvas ImageData

Moving a canvas pixel buffer to a worker with postMessage(payload, [buffer]) hands over the existing allocation instead of duplicating it, which turns a size-proportional copy into a constant-time ownership swap.

This page zooms in on one narrow mechanic inside the Image Processing in Workers guide, part of High-Performance Computation Patterns: how a canvas ImageData buffer crosses the thread boundary without being copied, and what breaks on the sending side once it has. The general form of the mechanism — which types qualify, what detaching means, how ownership travels — is covered in Transferable Objects & Zero-Copy; everything below is the canvas-specific application of it.

The Minimal Round Trip

The smallest complete example is two files: a main thread that reads pixels and gives them away, and a worker that mutates them in place and gives them back. Nothing here is optional — remove the transfer list and the same code silently degrades into a full structured clone in both directions.

// main.ts — the entire main-thread half of the round trip
const canvas = document.querySelector<HTMLCanvasElement>('#stage')!;
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;

interface FrameMessage {
  width: number;
  height: number;
  pixels: ArrayBuffer;
}

const worker = new Worker(new URL('./invert-worker.js', import.meta.url), {
  type: 'module',
});

worker.addEventListener('message', (event: MessageEvent<FrameMessage>) => {
  const { width, height, pixels } = event.data;
  // Uint8ClampedArray(ArrayBuffer) is a VIEW over the transferred memory,
  // not a copy — this line allocates 0 pixel bytes.
  const processed = new ImageData(new Uint8ClampedArray(pixels), width, height);
  ctx.putImageData(processed, 0, 0);
});

const frame: ImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels: ArrayBuffer = frame.data.buffer; // exactly 4 × w × h bytes

worker.postMessage(
  { width: frame.width, height: frame.height, pixels } satisfies FrameMessage,
  [pixels], // transfer list — ownership moves here
);

// frame.data.byteLength === 0 from this line onward. `frame` is a husk.
// invert-worker.js — worker internals stay plain JS so `type: 'module'` is the only contract
self.onmessage = (event) => {
  const { width, height, pixels } = event.data;

  // A view over memory this thread now owns outright.
  const px = new Uint8ClampedArray(pixels);

  for (let i = 0; i < px.length; i += 4) {
    px[i]     = 255 - px[i];     // R
    px[i + 1] = 255 - px[i + 1]; // G
    px[i + 2] = 255 - px[i + 2]; // B
    // px[i + 3] is alpha — leave it alone or the image goes translucent
  }

  // Hand the same allocation back. Without the second argument this
  // would copy 8.3 MB on the return leg and undo half the benefit.
  self.postMessage({ width, height, pixels }, [pixels]);
};
Who owns the pixel buffer at each beat of one round trip Four beats across two lanes. At beat one getImageData allocates 8,294,400 bytes on the main thread. At beat two postMessage with a transfer list moves that allocation into the worker lane and leaves a detached husk of byteLength zero behind. At beat three the worker mutates the bytes in place while the main thread stays detached. At beat four the worker posts the same allocation back, so the main thread owns 8,294,400 bytes again and the worker side is now the empty husk. One round trip: who owns the 1920 × 1080 pixel buffer 1 2 3 4 getImageData() transfer out invert in place transfer back Main thread Worker frame.data.buffer byteLength 8,294,400 nothing here yet detached husk byteLength 0 worker owns pixels byteLength 8,294,400 still detached byteLength 0 mutating in place byteLength 8,294,400 new ImageData(view) byteLength 8,294,400 detached husk byteLength 0 worker.postMessage(payload, [pixels]) self.postMessage({…}, [pixels]) 8,294,400 B = 1920 × 1080 × 4. It is the same allocation at every beat — the arrows move a pointer, never bytes. Each dashed block is a detached husk: the wrapper object still exists, its bytes do not.
Exactly one lane owns the allocation at any beat. Transfer does not duplicate the buffer — it hands it over and leaves an empty husk behind.

Walking Through the Critical Lines

Six lines in that example carry the whole behaviour; the rest is scaffolding.

getContext('2d', { willReadFrequently: true }) — tells the browser this canvas will be read back repeatedly, so it keeps the backing store in a CPU-readable form rather than re-syncing from the GPU on every getImageData. Without the hint, a per-frame readback pipeline pays a GPU-to-CPU stall that no amount of transfer optimisation can recover.

ctx.getImageData(...) — returns an ImageData whose data is a Uint8ClampedArray over a brand-new ArrayBuffer sized at exactly 4 × width × height bytes. That freshness is what makes the transfer safe: the canvas keeps its own internal backing store, so giving the returned buffer away costs you nothing the canvas needs.

frame.data.buffer — the transferable thing is the ArrayBuffer, never the ImageData and never the typed array. Reaching through .data.buffer is how you get at the transferable object underneath.

{ width, height, pixels } — an ArrayBuffer is a bag of bytes with no shape. Ship the dimensions in the payload, or the worker cannot tell 1920×1080 from 1080×1920 and new ImageData() will reject the mismatch. The satisfies FrameMessage annotation keeps the two ends of the channel in sync at compile time without widening the object’s inferred type.

[pixels] as the second argument — the buffer appears twice on purpose. Inside the payload it is what the worker receives; inside the transfer list it is what the structured-clone algorithm is told to move rather than duplicate. Include it in only the payload and you get a silent full copy; include it in only the transfer list and the worker gets an empty message.

new Uint8ClampedArray(pixels) — because the argument is an ArrayBuffer, this constructs a view over the transferred memory. This is the distinction the whole page rests on: new Uint8ClampedArray(someOtherTypedArray) would allocate and copy instead, quietly reintroducing the cost you just eliminated.

Anatomy of postMessage: payload versus transfer list Argument one is the payload object holding width, height and the pixels buffer. Argument two is the transfer list holding that same buffer again. They feed one postMessage call. The payload is what the worker reads as event.data; the transfer list is what moves ownership of the 8.3 megabyte allocation and detaches the sender's copy. The two arguments of one postMessage call argument 1 — the payload { width: 1920, height: 1080, pixels: ArrayBuffer } the same ArrayBuffer, twice argument 2 — transfer list [ pixels ] postMessage arg 1 + arg 2 what the worker receives event.data = { width, height, pixels } two numbers cloned (a few bytes), plus a handle to memory it now owns. what actually moved ownership of the 8.3 MB allocation. Nothing is copied, and frame.data.buffer is detached before the call returns. Drop argument 2 and the same call still works — it just copies all 8.3 MB instead of moving them.
The buffer is named twice on purpose: once in the payload, so the worker can find it, and once in the transfer list, so the engine moves it instead of copying it.

Gotchas & Edge Cases

The source detaches — it does not merely become read-only

After postMessage returns, frame.data.byteLength and frame.data.length are both 0, indexed reads yield undefined, and ctx.putImageData(frame, 0, 0) throws. There is no copy left behind and no way to reattach. The failure is silent in exactly the wrong way: a loop over a detached view runs zero iterations and reports success, so a filter that “does nothing” is usually a detached-buffer bug rather than a broken kernel.

worker.postMessage({ width, height, pixels }, [pixels]);

console.log(pixels.byteLength);        // 0
console.log(frame.data.length);        // 0 — the view detached with its buffer
worker.postMessage({ pixels }, [pixels]); // DataCloneError: already detached

Null the reference immediately after transfer so a stale read fails loudly rather than looking empty, and never hold a second view (a band, a scratch slice) over a buffer you are about to give away.

Transfer removes the copy, not the readback

getImageData itself is not free. Pulling a 4K frame out of the canvas means moving 33.2 MB across the GPU-CPU boundary, and that happens before any message is posted. The transfer list eliminates the serialisation cost, not the acquisition cost, so a per-frame pipeline that still calls getImageData on the main thread every frame remains main-thread-bound. When the readback is the bottleneck rather than the copy, the answer is to stop reading back at all: give the worker the surface itself, as described in Transferring Canvas Control to a Worker.

The same call is also the one that enforces canvas origin rules. If anything cross-origin has been drawn without crossorigin="anonymous" and a matching Access-Control-Allow-Origin response header, getImageData throws a SecurityError and the pipeline never starts.

ImageData is cloneable, but it is not transferable

Only ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas, and VideoFrame may appear in a transfer list. ImageData is not on that list — it is structured-cloneable, so worker.postMessage(imageData) works but copies every byte, while worker.postMessage(imageData, [imageData]) throws a DataCloneError. SharedArrayBuffer is the mirror-image trap: it is shareable but not transferable, so listing one in a transfer list also throws.

SharedArrayBuffer needs cross-origin isolation

If several workers must read the same pixels at once, ownership transfer is the wrong tool and SharedArrayBuffer is the right one — but it only exists on a cross-origin-isolated page, which requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp response headers. Without them SharedArrayBuffer is undefined at runtime and any third-party iframe lacking crossorigin stops loading. Branch on globalThis.crossOriginIsolated before assuming shared memory is available; for a single producer and a single consumer, a transferable ArrayBuffer is simpler, needs no headers, and is just as fast.

Byte length and dimensions must agree exactly

new ImageData(view, width, height) validates its arguments. If view.length is not a multiple of 4 it throws InvalidStateError; if it is a multiple of 4 but does not equal 4 × width × height it throws IndexSizeError. This bites when the worker returns a band of the image rather than the whole frame — the band’s height is not the frame’s height, and reusing the original dimensions produces an exception rather than a wrong picture. Send back the geometry that actually describes the bytes, and use the two-argument form new ImageData(view, width) when the height can be inferred.

Triage: the pixels did not come back One symptom branches into five outcomes. A byteLength of zero means the buffer was transferred and must be rebuilt from what the worker returns. A filter that runs zero iterations means the view was built over an already-detached buffer. DataCloneError means ImageData or SharedArrayBuffer was placed in the transfer list, or the buffer was already transferred. IndexSizeError means byteLength does not equal four times width times height. SecurityError means the canvas is tainted by cross-origin content. Triage: the pixels did not come back Symptom the filtered frame never reaches the canvas byteLength === 0 on the object you just posted Expected — ownership left. Rebuild ImageData from the buffer the worker posts back. filter ran zero iterations no error, no visible change The view was built over an already-detached buffer. Build it inside the worker, from event.data. DataCloneError thrown by postMessage() ImageData or SharedArrayBuffer in the transfer list, or that buffer was already transferred once. IndexSizeError thrown by new ImageData() byteLength ≠ 4 × width × height. Return the geometry that actually describes the bytes you send. SecurityError thrown by getImageData() Tainted canvas. Add crossorigin="anonymous" and a matching Access-Control-Allow-Origin header.
Only the last three branches throw. The first two fail silently, which is why a transfer bug usually looks like a filter that did nothing.

Keeping It Zero-Copy Across a Frame Loop

One round trip is easy; a sustained loop is where ownership discipline matters. Because a buffer has exactly one owner at a time, the main thread cannot post frame N+1 while frame N is still in the worker — the buffer simply is not there to send. A single in-flight slot plus a “latest wins” policy is almost always the right protocol for interactive filtering: stale frames are worthless the moment the user moves the slider again.

type Job = { width: number; height: number; pixels: ArrayBuffer };

let inFlight = false;
let pending: Job | null = null;

function submit(job: Job): void {
  if (inFlight) {
    pending = job;      // drop whatever was queued — only the newest frame matters
    return;
  }
  inFlight = true;
  worker.postMessage(job, [job.pixels]);
}

worker.addEventListener('message', ({ data }: MessageEvent<Job>) => {
  inFlight = false;
  ctx.putImageData(
    new ImageData(new Uint8ClampedArray(data.pixels), data.width, data.height),
    0, 0,
  );
  if (pending) {
    const next = pending;
    pending = null;
    submit(next);
  }
});

The returned buffer is also a resource worth recycling. At 60fps a 1080p pipeline that calls getImageData every frame asks the allocator for 8.3 MB per frame — half a gigabyte a minute of pure garbage. Keeping the returned buffer and writing the next frame’s pixels into it with ctx.getImageData()'s output copied once, or better, keeping the pixels resident in the worker across frames and only posting deltas, holds the heap flat and removes the allocation jitter that shows up as periodic frame drops in the Performance panel.

Buffer ownership across a frame loop Four states cycle clockwise: the main thread owns the buffer, the buffer is in flight to the worker, the worker owns it while filtering, and the buffer is in flight back. Only one side owns the allocation at any instant. Below the cycle, a newer frame arriving while the buffer is away goes into a pending slot that holds exactly one job; the older pending job is discarded and the newest is posted as soon as ownership returns to state A. Ownership across a frame loop, and the one pending slot A main thread owns free to post frame N+1 B in flight to worker owned by neither thread C worker owns filtering pixels in place D in flight back owned by neither thread worker.postMessage(job, [job.pixels]) ownership leaves the main thread worker onmessage self.postMessage({…}, [pixels]) the same allocation, returning main owns again exactly one owner at any instant while the buffer is away a newer frame arrives pending slot holds exactly one job the older pending job is discarded — the newest is posted the instant state A returns
Because the buffer has one owner, frame N+1 cannot be posted until frame N comes home. Coalescing into a single slot keeps the queue from growing stale.

Performance Note

The rule of thumb: structured clone costs roughly 1.2 ms per megabyte on a mid-range 2023 laptop, while a transfer costs a flat 0.05–0.1 ms no matter how big the buffer is. For canvas work that maps directly onto resolution, because RGBA is always 4 bytes per pixel.

Canvas Pixel bytes Structured clone (each way) Transfer (each way)
640×480 1.2 MB ~1.5 ms <0.1 ms
1280×720 3.7 MB ~4.4 ms <0.1 ms
1920×1080 8.3 MB ~10 ms <0.1 ms
3840×2160 33.2 MB ~40 ms <0.1 ms

Two details make the table worse than it looks for cloning. The cost is paid twice per round trip, out and back, so a cloned 4K pipeline burns roughly 80 ms of thread time per frame purely on copying — around five consecutive dropped frames against a 16.7 ms budget. And the copy transiently doubles the live heap, which is what turns a merely slow clone into a stuttering one when the collector fires mid-frame.

Measure it on your own hardware rather than trusting the table; the technique is a performance.now() bracket around the post, described in Measuring Structured Clone Cost with performance.now().

const t0 = performance.now();
worker.postMessage({ width, height, pixels }, [pixels]);
const posted = performance.now() - t0;
// Transfer: the number stays flat as the canvas grows.
// Clone (drop the transfer list): the number tracks byteLength almost linearly.
console.log(`${(pixels.byteLength / 1048576).toFixed(1)} MB → ${posted.toFixed(2)} ms`);

Because postMessage copies synchronously before it returns, that single bracket captures the entire clone cost — which is precisely why it is a main-thread problem and why the fix is one extra array argument.

Cost of one postMessage leg against pixel buffer size Buffer size runs from zero to 33.2 megabytes on the horizontal axis and cost per leg from zero to 45 milliseconds on the vertical axis. The structured clone series rises almost linearly through 1.5 milliseconds at 1.2 megabytes, 4.4 at 3.7, 10 at 8.3 and 40 at 33.2 megabytes, crossing the 16.7 millisecond frame budget at roughly 14 megabytes. The transfer series is a flat line pinned below 0.1 milliseconds at every size. One postMessage leg: clone cost scales, transfer cost does not milliseconds per leg 40 30 20 10 0 16.7 ms — one frame at 60fps structured clone — 40 ms at 33.2 MB beyond ≈ 14 MB a single clone eats the whole frame transfer — flat, under 0.1 ms at any size 1.2 MB 3.7 MB 8.3 MB 33.2 MB 640×480 1280×720 1920×1080 3840×2160 pixel buffer size — always 4 bytes per pixel Clone pays this cost on both legs: a 4K round trip spends roughly 80 ms copying. The transfer line stays flat on both.
The clone series is a slope; the transfer series is a constant. Everything above the dashed line is a frame you did not render.

Frequently Asked Questions

Why does transferring an ArrayBuffer cost less than structured-cloning ImageData?
Structured clone walks the value and duplicates every byte of the pixel buffer — 8.3 MB for 1080p, 33.2 MB for 4K — at roughly 1.2 ms per megabyte, and it briefly doubles heap pressure so a GC pause often lands in the same frame. A transfer list instead reassigns ownership of the existing allocation, which is a constant-time operation independent of buffer size. The price is that the source detaches: imageData.data.buffer.byteLength is 0 the instant postMessage returns.
Can I keep using the ImageData object after transferring its buffer?
No. The ImageData wrapper survives, but its data view is detached — byteLength and length both read 0, indexed reads return undefined, and passing it to ctx.putImageData() throws. Treat the object as consumed, drop the reference, and rebuild a fresh ImageData from the buffer the worker hands back. Attempting to transfer the detached buffer a second time throws a DataCloneError.

See also