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]);
};
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.
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.
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.
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.
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.