OffscreenCanvas and Workers for Animation
Part of Main-Thread Scheduling & Long Animation Frames in Performance Budgeting & GPU Architecture.
The problem
A dashboard renders a live network graph on a canvas: a few hundred nodes, a force simulation, sixty frames a second. It is smooth on its own, but the dashboard also loads data, renders tables and runs CSS animations, and when those happen the graph stutters — and worse, the graph’s per-frame work makes the tables’ entrance animations stutter too. Everything is competing for one thread.
Canvas rendering is one of the few kinds of animation that can genuinely move off the main thread.
Root cause analysis: one thread, two jobs
A canvas rendering loop runs script every frame: simulation, then drawing calls. All of it happens on the main thread, alongside style, layout, paint for the rest of the page, and every event handler. Two consequences follow. The canvas stutters whenever the page is busy, and the page stutters because the canvas is busy.
OffscreenCanvas decouples them. Calling canvas.transferControlToOffscreen() produces an OffscreenCanvas that can be transferred to a worker. The worker draws into it with the same 2D or WebGL API, and the results appear in the original element on the page. The main thread is not involved in drawing at all.
Workers have their own animation frames. requestAnimationFrame exists in workers and is driven by the same display cadence, so the worker’s loop stays in step with the page’s rendering while running independently of main-thread work.
Not everything moves. The worker has no DOM: no getBoundingClientRect(), no event listeners, no CSS. Input, layout and sizing stay on the main thread and are communicated by messages. Fonts can be loaded in workers, but text measurement APIs differ from the main thread’s.
Messages should be small. Posting state — a pointer position, a data update, a new size — is cheap. Posting pixel data every frame defeats the purpose; transfer or share buffers instead where large data is unavoidable.
It is not always the right answer. A handful of moving elements is cheaper and simpler as CSS animation on compositor-friendly properties. Canvas earns its place when the number of drawn items is large, when the visual is generative, or when pixel-level effects are needed.
Step-by-step resolution
Production code pattern
// main.js
const canvas = document.querySelector('#graph');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
if ('transferControlToOffscreen' in canvas) {
const worker = new Worker('/graph-worker.js', { type: 'module' });
const offscreen = canvas.transferControlToOffscreen(); // main thread can no longer draw into it
worker.postMessage({ type: 'init', canvas: offscreen, reduced: reduce.matches }, [offscreen]);
const sendSize = () => {
const r = canvas.getBoundingClientRect(); // DOM measurement stays here
worker.postMessage({ type: 'resize', width: r.width, height: r.height,
dpr: Math.min(devicePixelRatio || 1, 2) });
};
new ResizeObserver(sendSize).observe(canvas);
sendSize();
canvas.addEventListener('pointermove', (e) => {
worker.postMessage({ type: 'pointer', x: e.offsetX, y: e.offsetY }); // small, per event
});
document.addEventListener('visibilitychange', () =>
worker.postMessage({ type: document.hidden ? 'pause' : 'resume' }));
reduce.addEventListener('change', (e) => worker.postMessage({ type: 'reduced', value: e.matches }));
} else {
startMainThreadFallback(canvas); // same drawing code, on this thread
}
// graph-worker.js
let ctx, raf = 0, state = { dpr: 1, reduced: false };
self.onmessage = ({ data }) => {
switch (data.type) {
case 'init':
ctx = data.canvas.getContext('2d');
state.reduced = data.reduced;
if (!state.reduced) loop();
break;
case 'resize':
Object.assign(state, data);
ctx.canvas.width = Math.round(data.width * data.dpr);
ctx.canvas.height = Math.round(data.height * data.dpr);
ctx.setTransform(data.dpr, 0, 0, data.dpr, 0, 0);
break;
case 'pointer': state.pointer = data; break;
case 'pause': cancelAnimationFrame(raf); raf = 0; break;
case 'resume': if (!raf && !state.reduced) loop(); break;
case 'reduced':
state.reduced = data.value;
if (state.reduced) { cancelAnimationFrame(raf); raf = 0; drawStatic(); } else loop();
break;
}
};
function loop() {
raf = requestAnimationFrame(loop);
simulate(state);
draw(ctx, state);
}
Rendering Impact: none on the main thread once transferred. The worker’s drawing is composited like any canvas; page animations and input handling no longer compete with the simulation.
Once transferControlToOffscreen() has been called, the main thread cannot draw into that canvas — including a fallback path — so the decision is made at initialisation. Keeping the drawing functions in a module imported by both the worker and the fallback avoids maintaining two implementations.
When a worker is not the answer
If the animation is a handful of elements moving, CSS is simpler and cheaper: the compositor animates them without any script at all. If the canvas is mostly static and updates a few times a second, the main thread handles it comfortably. If the visual depends on live DOM measurement every frame, the message round-trip may cost more than it saves.
The clearest wins are continuous, self-contained simulations: particle fields, force-directed graphs, audio visualisers, generative backgrounds. These run for a long time, need no DOM, and are exactly the workloads that make the rest of the page stutter when they share a thread — the scenario described in yielding to the main thread during animations, where yielding helps but cannot remove the work.
Verification checklist
Constraints and trade-offs
- After transfer, the main thread cannot draw into the canvas at all.
- Workers have no DOM, so measurement and events must be messaged.
- Debugging worker code is harder; DevTools shows it as a separate context.
- Very frequent messages have their own cost; batch or throttle pointer updates.
- Text rendering and font handling in workers differ from the main thread.
Frequently asked questions
What does OffscreenCanvas do?
It lets canvas rendering happen off the main thread, typically in a worker, while the result still appears in the canvas element on the page.
Can I still draw from the main thread after transferControlToOffscreen?
No. Control is transferred; the main thread can no longer get a context for that canvas.
Do workers have requestAnimationFrame?
Yes. Workers with an OffscreenCanvas can drive their loop with requestAnimationFrame, in step with the display.
Should every canvas animation move to a worker?
No. Small or infrequently updated canvases are fine on the main thread, and simple motion is better expressed as CSS animation.
Related
- Main-Thread Scheduling & Long Animation Frames — the parent topic
- Video and Canvas Layer Memory Costs — sizing the canvas itself
- Pausing Off-Screen and Background-Tab Animations — stopping worker loops nobody sees