Transferring an OffscreenCanvas to a Web Worker
Your chart’s draw loop is pinning the main thread, so scrolling, input, and React updates all stall while the canvas paints thousands of points.
The fix is to hand the canvas to a Web Worker with canvas.transferControlToOffscreen() so all drawing happens off the main thread. This guide sits under offscreen canvas rendering, part of the high-performance animation and GPU acceleration overview.
Diagnostic checklist
Architecture
What “transfer” actually means
transferControlToOffscreen() does not copy the canvas — it hands ownership of the canvas’s backing surface to an OffscreenCanvas object that can be sent to a worker as a Transferable. Once transferred, the original <canvas> element on the main thread is permanently detached from its rendering context: you can never draw to it directly again, and any later call to getContext on it throws. The element still occupies layout and still receives pointer events — which is exactly what you want, because input handling belongs on the main thread where the DOM lives — but its pixels are now produced entirely by the worker. This split is the whole point: the main thread keeps responsibility for layout, hit-testing, and input, while the worker owns the expensive, uninterruptible draw loop.
Because the worker has no DOM, no window, and no access to CSS, everything the render code needs must be sent over postMessage: the device pixel ratio, the logical dimensions, fonts, and of course the data. The art of a good worker-canvas architecture is keeping that message traffic small and using Transferables (typed-array buffers) for the data so it is moved rather than structured-cloned. A naive implementation that serializes a large array of point objects on every frame will simply move the bottleneck from drawing to message serialization.
Broken vs fixed
// ❌ BROKEN: drawing thousands of points on the main thread.
// The draw loop blocks input and layout; INP and scroll suffer.
const canvas = document.querySelector('canvas')!;
const ctx = canvas.getContext('2d')!; // claims the context on main thread
function frame(points: Float32Array): void {
ctx.clearRect(0, 0, canvas.width, canvas.height); // PERF: blocks the main thread
for (let i = 0; i < points.length; i += 2) ctx.fillRect(points[i], points[i + 1], 2, 2);
}
// ✅ FIXED: transfer control to a worker; main thread stays free.
const canvas = document.querySelector('canvas')!;
// NOTE: do NOT call getContext on the main thread first — transfer would throw.
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' });
// PERF: the canvas is a Transferable — moved, not copied (zero-copy).
worker.postMessage(
{ type: 'init', canvas: offscreen, dpr: window.devicePixelRatio },
[offscreen],
);
// A11Y: keep an accessible data-table mirror on the main thread; the
// worker-rendered canvas is invisible to assistive tech on its own.
Step-by-step fix
// render.worker.ts
type InitMsg = { type: 'init'; canvas: OffscreenCanvas; dpr: number };
type DataMsg = { type: 'data'; points: Float32Array };
type ResizeMsg = { type: 'resize'; width: number; height: number; dpr: number };
type Msg = InitMsg | DataMsg | ResizeMsg;
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let canvas: OffscreenCanvas | null = null;
let points = new Float32Array(0);
self.onmessage = (e: MessageEvent<Msg>): void => {
const msg = e.data;
if (msg.type === 'init') {
canvas = msg.canvas;
ctx = canvas.getContext('2d'); // Step 3: context lives in the worker
applyDpr(msg.dpr);
requestAnimationFrame(draw); // Step 5: rAF works inside the worker
} else if (msg.type === 'data') {
points = msg.points; // Step 4: transferred buffer, zero-copy
} else if (msg.type === 'resize') {
if (canvas) { canvas.width = msg.width * msg.dpr; canvas.height = msg.height * msg.dpr; }
applyDpr(msg.dpr);
}
};
function applyDpr(dpr: number): void {
// PERF: scale once on resize, not every frame
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(): void {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < points.length; i += 2) ctx.fillRect(points[i], points[i + 1], 2, 2);
requestAnimationFrame(draw);
}
// main thread: forward resize and stream data
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
worker.postMessage({ type: 'resize', width, height, dpr: window.devicePixelRatio });
});
ro.observe(canvas);
function stream(points: Float32Array): void {
// transfer the buffer so it is moved, not structured-cloned
worker.postMessage({ type: 'data', points }, [points.buffer]);
}
Verification
// Assert the canvas was transferred: the main thread can no longer get a context.
try {
canvas.getContext('2d');
console.assert(false, 'expected getContext to throw after transfer');
} catch {
console.assert(true, 'canvas control was transferred to the worker');
}
With the worker active, record the main thread in DevTools while data streams: the long canvas draw tasks should now appear under the worker thread, and the main thread should stay responsive to scroll and input. In the Performance panel’s thread tracks you should see the previously dominant clearRect/fill work relocate to the worker’s track, while the main thread’s track goes nearly idle during animation. If the main thread is still busy, the bottleneck was never drawing — it was the data processing you do before the draw, and moving the canvas will not have helped. In that case, move the data transform into the worker as well, or pre-aggregate it.
A second thing to verify is message overhead. Open the worker’s track and confirm that data messages are cheap. If you see significant time deserializing each message, you are probably cloning objects instead of transferring buffers — switch the payload to a Float32Array and pass [buffer] as the transfer list, as the stream function above does.
Lifecycle and teardown
A worker-backed canvas has a longer lifecycle than a plain one, and skipping teardown leaks both memory and CPU. The worker keeps running its requestAnimationFrame loop until you stop it, even after the component that owned the canvas has unmounted. In a single-page application that navigates between dashboards, a forgotten worker keeps drawing into a detached surface forever, burning a CPU core in the background.
The clean shutdown sequence is: stop the worker’s draw loop (send a stop message that cancels the in-flight requestAnimationFrame), then call worker.terminate() from the main thread to release the worker entirely. Wire both into your framework’s cleanup hook — React’s useEffect return, Vue’s unmounted, Svelte’s onDestroy. Also disconnect the ResizeObserver you attached to the canvas; an observer that outlives its target is a small but real leak and will keep firing resize messages at a terminated worker, producing console errors. Because the canvas element itself is detached once control is transferred, you do not need to (and cannot) restore its context — simply removing the element from the DOM is enough on that side.
Edge cases and gotchas
- Transfer is one-way and one-time. After
transferControlToOffscreen, the main-thread canvas is detached forever; you cannot draw to it again or transfer it twice. If you need to recreate the visualization, create a fresh<canvas>element and transfer that. - No DOM, no fonts-by-name guarantees in the worker. The worker has no document, so a font referenced only in CSS is not available. Load fonts via the worker’s
FontFaceAPI andawaitfontFace.load()before drawing text, or text metrics and rendering will silently fall back to a default and look wrong. - Provide a fallback. Feature-detect
'transferControlToOffscreen' in HTMLCanvasElement.prototype; when absent, draw on the main thread with the same render function, ideally time-sliced into chunks so a single draw does not blow the frame budget. Structuring the render function so it accepts either aCanvasRenderingContext2Dor anOffscreenCanvasRenderingContext2Dlets you share one code path between the worker and the fallback. The synchronous-update sibling guide implementing offscreen canvas for background chart updates covers the non-worker variant in detail.
Related
- Offscreen canvas rendering — the parent guide on moving canvas work off the critical path.
- Implementing offscreen canvas for background chart updates — the same canvas without a worker.
- Core rendering engines and tradeoffs — when canvas is the right surface in the first place.