Transferring Canvas Control to a Worker
canvas.transferControlToOffscreen() is the single API call that hands a page’s <canvas> element to a worker thread — after it returns, every pixel the user sees is painted from code that never touches the main thread.
This is the entry point to OffscreenCanvas Rendering within High-Performance Computation Patterns, and it is worth being precise about what the call does: it does not copy the canvas, and it does not give the worker a second canvas. It moves ownership of the element’s rendering surface, exactly the way transferable objects move ownership of an ArrayBuffer. The DOM element stays where it is and keeps its layout box, but it becomes a passive display surface fed by the compositor, and the main thread permanently loses the ability to draw into it.
Minimal Reproducible Example
Two files — main.ts on the page and render-worker.ts in the worker — are all you need to get a canvas rendering entirely off the main thread.
HTML
<!-- index.html -->
<canvas id="chart" width="800" height="400"></canvas>
<script type="module" src="./main.ts"></script>
Main Thread
// main.ts
const canvas = document.getElementById('chart') as HTMLCanvasElement;
if (!('OffscreenCanvas' in globalThis)) {
throw new Error('OffscreenCanvas is not supported in this browser.');
}
// Step 1: Transfer control. This is a one-way, irreversible operation.
const offscreen: OffscreenCanvas = canvas.transferControlToOffscreen();
// Step 2: Spawn the worker.
const worker = new Worker(new URL('./render-worker.ts', import.meta.url), {
type: 'module',
});
// Step 3: Post the OffscreenCanvas. It MUST be in the transfer list.
worker.postMessage(
{
type: 'init',
canvas: offscreen,
width: canvas.clientWidth,
height: canvas.clientHeight,
dpr: window.devicePixelRatio,
},
[offscreen] // transfer list — ownership moves, source is now detached
);
// Step 4: Forward pointer events to the worker (input stays on main thread).
canvas.addEventListener('pointermove', (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
worker.postMessage({
type: 'pointer',
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
});
// Step 5: Forward resize events. ResizeObserver does not exist in a worker.
const ro = new ResizeObserver(() => {
worker.postMessage({
type: 'resize',
width: canvas.clientWidth,
height: canvas.clientHeight,
dpr: window.devicePixelRatio,
});
});
ro.observe(canvas);
worker.onerror = (e: ErrorEvent) => {
console.error('Render worker error:', e.message, e.filename, e.lineno);
};
Worker
// render-worker.ts
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let frameWidth = 0;
let frameHeight = 0;
let dpr = 1;
interface InitMessage {
type: 'init';
canvas: OffscreenCanvas;
width: number;
height: number;
dpr: number;
}
interface ResizeMessage {
type: 'resize';
width: number;
height: number;
dpr: number;
}
interface PointerMessage {
type: 'pointer';
x: number;
y: number;
}
type WorkerMessage = InitMessage | ResizeMessage | PointerMessage;
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
const msg = e.data;
if (msg.type === 'init') {
dpr = msg.dpr;
frameWidth = Math.round(msg.width * dpr);
frameHeight = Math.round(msg.height * dpr);
msg.canvas.width = frameWidth;
msg.canvas.height = frameHeight;
ctx = msg.canvas.getContext('2d');
if (!ctx) {
self.postMessage({ type: 'error', message: 'Failed to get 2d context' });
return;
}
ctx.scale(dpr, dpr);
self.requestAnimationFrame(renderFrame);
}
if (msg.type === 'resize') {
dpr = msg.dpr;
frameWidth = Math.round(msg.width * dpr);
frameHeight = Math.round(msg.height * dpr);
if (ctx) {
ctx.canvas.width = frameWidth;
ctx.canvas.height = frameHeight;
ctx.scale(dpr, dpr); // resize clears the transform — restore it
}
}
};
function renderFrame(timestamp: number): void {
if (!ctx) return;
const cssWidth = frameWidth / dpr;
const cssHeight = frameHeight / dpr;
ctx.clearRect(0, 0, cssWidth, cssHeight);
// Example: animated gradient bar
const progress = (Math.sin(timestamp / 800) + 1) / 2;
ctx.fillStyle = '#3b82f6';
ctx.fillRect(40, 40, (cssWidth - 80) * progress, 60);
ctx.fillStyle = '#1e293b';
ctx.font = '14px system-ui, sans-serif';
ctx.fillText(`timestamp: ${timestamp.toFixed(0)} ms`, 40, 130);
self.requestAnimationFrame(renderFrame);
}
transferControlToOffscreen() yields a handle, postMessage's transfer list moves that handle's ownership across the boundary, and the main thread is left holding a detached stub while the compositor keeps presenting whatever the worker paints.Line-by-Line Walkthrough
const offscreen: OffscreenCanvas = canvas.transferControlToOffscreen();
Returns an OffscreenCanvas whose initial dimensions match the width/height attributes of the original element (300×150 if you never set them — a classic source of “why is my chart squashed”). The DOM element continues to exist and continues to size the on-page space, but it is now a presentation surface driven by the compositor rather than something you can draw into. The call is valid exactly once per element, and only while the element has no rendering context.
worker.postMessage({ type: 'init', canvas: offscreen, ... }, [offscreen]);
The second argument is the transfer list, and this is the line people get wrong. Naming offscreen there tells the browser to move ownership instead of attempting a copy; leave it out and the structured clone algorithm runs, discovers a type it cannot serialize, and throws DataCloneError. After a successful transfer, offscreen.width and offscreen.height read 0 on the main thread and any further use of the object throws InvalidStateError — the handle is detached, exactly like a transferred ArrayBuffer.
ctx = msg.canvas.getContext('2d');
Inside the worker this behaves identically to HTMLCanvasElement.getContext('2d'). OffscreenCanvasRenderingContext2D implements the complete Canvas 2D surface — paths, fills, strokes, gradients, patterns, transforms, clipping, text metrics and drawImage — with one substitution: image sources must be ImageBitmap, OffscreenCanvas or VideoFrame rather than DOM <img>/<video> elements, because those types do not exist in a worker. Decode with createImageBitmap(), which is available in workers and is itself transferable; the mechanics are covered in using transferable objects for canvas image data.
self.requestAnimationFrame(renderFrame);
This is DedicatedWorkerGlobalScope.requestAnimationFrame, not a shim. The compositor schedules it at the display refresh rate and hands the callback a DOMHighResTimeStamp on the same clock as the main thread’s version. Because it is driven by the compositor rather than by the page’s event loop, a 200 ms task on the main thread no longer delays a frame — that decoupling is the entire point of the exercise.
ctx.canvas.width = frameWidth;
Assigning to width or height — on a DOM canvas or an OffscreenCanvas — resets the surface: pixels are cleared and the context state (transform, fill and stroke styles, line width, font, clip) returns to defaults. Reapply everything that matters immediately afterwards. In the example that means the ctx.scale(dpr, dpr) on the line below; in real code it usually means calling a small applyContextDefaults(ctx) helper from both the init and resize branches.
Feature Detection and the Fallback Path
Every current engine ships the API — Chrome 69+, Edge 79+, Firefox 105+, Safari 16.4+ — but detection is still worth the four lines, because the failure mode without it is a blank rectangle rather than a degraded chart. Test for the method on the prototype, not just for the global:
// canvas-renderer.ts — one entry point, two implementations
export function mountRenderer(canvas: HTMLCanvasElement): () => void {
const supported =
'OffscreenCanvas' in globalThis &&
typeof HTMLCanvasElement.prototype.transferControlToOffscreen === 'function';
if (!supported) {
return mountMainThreadRenderer(canvas); // same draw code, main-thread rAF
}
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(new URL('./render-worker.ts', import.meta.url), {
type: 'module',
});
worker.postMessage(
{ type: 'init', canvas: offscreen, dpr: devicePixelRatio,
width: canvas.clientWidth, height: canvas.clientHeight },
[offscreen],
);
return () => worker.terminate(); // teardown handle for the caller
}
Keeping the draw function in a module imported by both the worker and the fallback means one implementation of the visuals and two schedulers. Note that new URL('./render-worker.ts', import.meta.url) is not decoration: it is the form bundlers statically recognise, and bundling module workers with Vite and webpack explains why a string path silently ships a 404 in production.
transferControlToOffscreen() is single-use, restarting a render worker is an element swap, not just a new Worker().Gotchas and Edge Cases
1. You Can Only Transfer Once
transferControlToOffscreen() is single-use per canvas element; a second call throws InvalidStateError, and so does calling it on an element whose context you already acquired. This makes worker restart a DOM problem rather than a worker problem: if the render worker dies, you cannot simply spawn a replacement and hand it the same canvas. The recovery path is to remove the old <canvas>, insert a fresh one, transfer its control to the new worker, and terminate the old one. Dashboards and long-lived visualizations should build that path deliberately — the same lifecycle discipline described in handling worker termination gracefully in SPAs applies, with the element swap added.
2. The DOM Canvas Becomes a Placeholder
After transfer the element still occupies layout space, still receives pointer, wheel and keyboard events, and still has the worker’s output composited into it automatically — but it has no context of its own. canvas.getContext('2d') throws InvalidStateError in current Chrome and Firefox and behaves inconsistently in older engines, and canvas.toDataURL() / canvas.getContext('2d')?.getImageData() are equally unavailable. Anything that needs pixels back — an “export PNG” button, a visual regression test — must ask the worker, which can call canvas.convertToBlob() on its OffscreenCanvas and post the resulting Blob back.
3. Input Events Stay on the Main Thread
A worker has no DOM, so hit-testing is a message-passing problem. Listen on the DOM canvas, convert client coordinates to canvas-local coordinates with getBoundingClientRect(), and post them. High-frequency streams are the trap: pointermove on a 120 Hz trackpad, or a wheel handler, can emit far more events than there are frames, and because postMessage never blocks, the worker’s message queue grows until the renderer is chasing input from several frames ago. Coalesce to one message per frame:
// main.ts — throttle pointermove to one message per frame
let pendingPointer: { x: number; y: number } | null = null;
canvas.addEventListener('pointermove', (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
pendingPointer = { x: e.clientX - rect.left, y: e.clientY - rect.top };
});
function flushPointer(): void {
if (pendingPointer) {
worker.postMessage({ type: 'pointer', ...pendingPointer });
pendingPointer = null;
}
requestAnimationFrame(flushPointer);
}
requestAnimationFrame(flushPointer);
Keeping only the newest sample is the right policy for hover and drag, where stale positions have no value. Gestures that need every sample — freehand drawing, velocity-based inertia — should batch the coalesced events from PointerEvent.getCoalescedEvents() into a single message instead of dropping them.
4. Sizing: CSS vs Attribute vs devicePixelRatio
Three distinct size concepts must stay synchronized, and confusing any two of them is the most common reason worker-rendered canvases look wrong:
- CSS size (
canvas.style.width/height, or whatever layout computes): the physical space the element occupies on screen. - Buffer size (
canvas.width/heightbefore transfer,offscreen.width/heightafter): the resolution of the pixel buffer being drawn into. - devicePixelRatio: the ratio of device pixels to CSS pixels — 2 on most Retina displays, 1.5, 1.75 and 2.625 on common Android devices, and a fractional, changing value under browser zoom.
Set offscreen.width = cssWidth * dpr and offscreen.height = cssHeight * dpr, then ctx.scale(dpr, dpr) so drawing coordinates stay in CSS pixels. Skip the multiplication and HiDPI output is visibly soft; skip the scale and everything renders at 1× in the corner of a larger buffer. Because DPR changes when a window moves between monitors or the user zooms, re-read window.devicePixelRatio inside the ResizeObserver callback rather than caching it at startup.
devicePixelRatio; ctx.scale(dpr, dpr) hands the drawing code its CSS-pixel coordinate system back. Both assignments are wiped whenever width or height is set again.Neither ResizeObserver nor window.matchMedia exists in a worker scope, so the worker cannot observe its own size, the DPR, prefers-color-scheme or prefers-reduced-motion. Every one of those signals has to be observed on the page and posted in. Treat the init message as a snapshot of the environment and add a dedicated env message for later changes — it is far cheaper than discovering at review time that a themed chart never repaints when the user flips to dark mode.
5. Transfer Happens Before the Worker Is Ready — and That Is Fine
postMessage on a freshly constructed Worker is safe even though the worker script has not finished loading: messages are queued against the port and delivered once the global scope installs its onmessage handler. What is not safe is assuming the reverse — the main thread learns nothing about a failed context acquisition unless the worker tells it. Keep the worker.onerror handler from the example, and have the worker post an explicit { type: 'error' } when getContext returns null, which happens in practice when a page has exhausted the per-page WebGL context limit or when the tab is under memory pressure.
Performance Note
The transfer itself is effectively free and effectively constant: the handle is a pointer hand-off, so postMessage with the canvas in the transfer list costs well under 0.1 ms regardless of canvas size — a 4000×4000 surface transfers as fast as a 300×150 one. Nothing is copied, which is why this is a startup-time cost you never think about again.
The gain shows up in frame delivery, not frame cost. Moving an existing 60 fps loop into a worker removes its 4–12 ms of per-frame main-thread work on a mid-range 2023 laptop, and — more importantly — takes the render loop out of the queue behind layout, script and garbage collection. On a page with virtualized lists, CSS animations and frequent DOM mutation, that is the difference between a steady 16.7 ms cadence and periodic 40–80 ms stalls whenever the main thread has a bad frame. The corollary matters just as much: if a single frame already takes 40 ms of drawing, it will still take 40 ms in the worker. OffscreenCanvas makes frames reliable, not cheaper. Verify the split in the Chrome Performance panel — paint work and rAF callbacks should appear in the Worker track with the Main track idle, using the workflow in profiling worker CPU usage with the Chrome Performance tab.
requestAnimationFrame is scheduled by the compositor and keeps cadence through both.For the complete implementation pattern built on top of this transfer — streaming data updates, ring buffers and chart-specific draw optimizations — continue to Rendering Charts Off the Main Thread.