← blog
JavaScriptNovember 3, 2026 · 6 min read

Your Canvas Is Freezing the Page. OffscreenCanvas Fixes That.

Click a button while your canvas animation is running and the whole page stalls — because the pixels it draws, your layout, and your input handling all queue up on the same main thread. OffscreenCanvas moves the drawing itself into a worker, so the UI keeps responding no matter how much canvas work is happening.

Parsa Jiravand · Frontend engineer · building bestpractic
Your Canvas Is Freezing the Page. OffscreenCanvas Fixes That.

You open the profiler, record a session, and the flame chart looks great. 60 frames a second, steady, no dropped frames. You ship it.

Then someone clicks a button while the animation is running, and for half a second, nothing happens. Not a dropped frame — the whole page. The click just sits there, queued, waiting for a thread that's busy painting pixels nobody asked it to interrupt.

Here's the part that doesn't show up in a frame-rate graph: your canvas animation was never competing with itself. It was competing with every click handler, every layout pass, every bit of input handling on the page — because by default, all of that runs on exactly one thread, and canvas drawing calls are first-class citizens on it.

The instinct is to blame the math. Heavy canvas work — thousands of particles, a live chart redrawing every tick, a game loop doing collision checks — looks like a computation problem, so people reach for a Web Worker to do the computation off-thread.

JavaScript
1
2
3
4
5
6
7
8
9
// main.js const worker = new Worker("particles-worker.js"); worker.postMessage({ type: "tick" }); worker.onmessage = (e) => { const positions = e.data.positions; // ...now draw them, back on the main thread ctx.clearRect(0, 0, canvas.width, canvas.height); for (const p of positions) ctx.fillRect(p.x, p.y, 2, 2); };

This genuinely helps — the position math moves off the main thread. But watch what's still happening in that onmessage handler: clearRect, fillRect, every actual drawing call, is still running on the main thread, because that's the only thread with access to the <canvas> element's rendering context. You moved the arithmetic. You didn't move the part that was actually fighting your clicks for time.

A <canvas> element's 2D or WebGL context has always been tied to the DOM, and anything tied to the DOM only exists on the main thread. That's not a performance detail, it's a structural one: a worker has no document, so it has never been able to hold a canvas context at all — until there was a way to give it one that isn't attached to the page.

That's what OffscreenCanvas is. Not a faster canvas. A canvas whose rendering context can live somewhere other than the main thread, so the fastest possible canvas is one where the drawing calls stop queuing up behind your click listeners in the first place.

HTML
<canvas id="scene" width="600" height="400"></canvas>
JavaScript
1
2
3
4
5
6
7
8
9
10
// main.js const canvas = document.getElementById("scene"); const offscreen = canvas.transferControlToOffscreen(); const worker = new Worker("render-worker.js"); worker.postMessage({ canvas: offscreen }, [offscreen]); // the main thread still owns input — forward what the worker needs canvas.addEventListener("pointermove", (e) => { worker.postMessage({ type: "pointer", x: e.offsetX, y: e.offsetY }); });
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// render-worker.js let ctx; let pointer = { x: 0, y: 0 }; self.onmessage = (e) => { if (e.data.canvas) { ctx = e.data.canvas.getContext("2d"); requestAnimationFrame(loop); } if (e.data.type === "pointer") pointer = e.data; }; function loop() { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.beginPath(); ctx.arc(pointer.x, pointer.y, 20, 0, Math.PI * 2); ctx.fill(); requestAnimationFrame(loop); }

transferControlToOffscreen() detaches the canvas element from its own rendering context and hands you an OffscreenCanvas you can transfer to a worker like any other transferable object. Inside the worker, getContext("2d") gives back something that draws almost exactly like the context you're used to — same fillRect, same beginPath, same drawImage — except every one of those calls now runs on a thread that has nothing else to do but draw.

The one thing that doesn't come along for free is input. A worker has no DOM, so it can't attach its own pointermove or click listeners to anything. The main thread stays the one place that owns events, and your job is to forward the few fields the worker actually needs — as shown above, just x and y, not the whole event object, which isn't transferable anyway.

Open two tabs in your head. In the first, with everything on the main thread, a click has to wait in the same queue as every pending fillRect call — if the canvas is mid-frame, the click waits for the frame to finish. In the second, with OffscreenCanvas, the canvas's frame is happening on a different thread entirely. The main thread's event queue is short again, because the one thing that used to dominate it moved out.

This is the distinction worth sitting with: OffscreenCanvas doesn't make your particle system compute faster, and it doesn't raise your frame rate. It makes the main thread's queue shorter by taking an entire category of work out of it. A canvas that was drawing 10,000 particles at 60fps before will still be drawing 10,000 particles at roughly 60fps after — the difference is that a click, a keypress, or a scroll now gets handled in the same frame instead of after it.

Runs right in your browser — poke at it and watch the concept react live.

Not every canvas needs this. A sparkline that redraws once when new data arrives, a static chart, an icon you draw once on mount — none of that is holding the main thread hostage, because none of it is running every frame. The tell isn't "I use canvas," it's "my canvas work and my users' clicks are both trying to happen right now, repeatedly, for as long as the page is open." Games, live data visualizations, drawing tools, anything with a requestAnimationFrame loop that runs indefinitely — that's the category where this earns its complexity.

It's also not free complexity. You lose direct DOM access inside the worker, so anything that reads layout, measures text against real fonts in edge cases, or needs synchronous access to other page state has to be designed around message passing from the start, not bolted on later.

Yes. Chrome has supported OffscreenCanvas since 2018, Firefox enabled it for workers a few years after that, and Safari was the last to land it, in Safari 16.4 in March 2023. All three major engines have carried it for a while now, so there's no meaningful fallback story you need to write — check "OffscreenCanvas" in window if you want a belt-and-suspenders guard, but you're unlikely to need it.

The next time a profiler shows you a perfectly smooth 60fps canvas sitting on top of a page that still stutters when someone clicks, you'll know the frame rate was never the number that mattered.

What's the heaviest thing you're currently drawing to a <canvas> on the main thread? I'm curious how many of you are one transferControlToOffscreen() call away from fixing a bug you've been blaming on something else.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.