Rendering with OffscreenCanvas in a Web Worker
A canvas that redraws every frame from the main thread runs its draw calls and 2D rasterization inside the one main-thread task queue, so each frameβs script blocks input dispatch and delays the next requestAnimationFrame β the symptom is a spiking INP and a stuttering animation while the GPU sits mostly idle. This guide is part of Off-Main-Thread Rendering, within Compositing and GPU Acceleration. The fix is to move the drawing surface β and the loop that paints it β onto a worker with OffscreenCanvas and transferControlToOffscreen(), leaving the main thread free to answer clicks and scrolls.
Why a Main-Thread Canvas Steals the Frame Budget
A <canvas> elementβs 2D or WebGL context executes wherever the context was created. Create it on the main thread and every fillRect, drawImage, or shader submission is a main-thread instruction, tokenized into the same task that runs your React reconciler, your event listeners, and your layout code. The browserβs event loop is run-to-completion: once a task starts it cannot be interrupted, so a 38ms draw pass holds the thread for more than two frames, and any pointermove or click that arrives mid-draw waits in the queue until the draw finishes. That queued delay is exactly what the Interaction to Next Paint metric measures, which is why a heavy canvas tanks INP even when the animation itself looks smooth in isolation.
Crucially, none of this competes with the compositor for GPU time β it competes for the main thread. The pixels the canvas produces still hand off to the compositor thread the same way a promoted layer does, but the CPU-side work of issuing draw commands and rasterizing them (for a 2D context) is serialized against everything else your page wants to do. You can confirm the starvation with the Long Tasks API: each frame shows up as a >50ms task with the interaction stuck behind it.
Minimal reproduction
The pattern below is the standard βjust draw in rAFβ loop. It works, it is idiomatic, and on a busy scene it holds the main thread hostage:
// main.js β everything runs on the main thread
const canvas = document.querySelector('#viz')
const ctx = canvas.getContext('2d')
function frame(t) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (let i = 0; i < 40000; i++) {
drawParticle(ctx, i, t) // β every draw call executes in the main-thread task queue
}
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
The for loop is the responsible line: 40,000 drawParticle calls per frame are 40,000 main-thread instructions plus the 2D raster they trigger, all inside one uninterruptible task. Nothing here is a forced synchronous layout β it is pure scripting and painting cost β but the effect on interaction latency is the same: the thread is busy, so the page is unresponsive.
How transferControlToOffscreen Moves Rendering Off-Thread
OffscreenCanvas is a drawing surface that is not tied to the DOM. You obtain one from an existing <canvas> by calling transferControlToOffscreen(), which detaches the elementβs backing surface and returns an OffscreenCanvas handle. From that moment the main-thread <canvas> becomes a placeholder: it still occupies its box in layout and still displays whatever the surface contains, but you can no longer call getContext() on it β the surface now lives elsewhere. You post the OffscreenCanvas to a worker inside a messageβs transfer list, so it moves by reference rather than being structured-cloned, and the worker calls getContext('2d') (or 'webgl2', or 'webgpu') on it.
Once the context lives on the worker, the render loop lives there too. A worker has its own requestAnimationFrame driven by the same vsync signal, so frame() ticks at display refresh on the worker thread. When the worker draws, the produced bitmap is committed and the compositor picks it up and paints the placeholder β no main-thread involvement per frame. The main threadβs only remaining job is to forward small state deltas (pointer position, resize dimensions, a βpauseβ flag) via postMessage, which are kilobyte-sized structured clones, not per-frame pixel work.
The trace signature
Captured in the Performance panel, the before/after difference is unmistakable. Before, the whole frame is one long main-thread task with the interaction stranded behind it:
Main thread βΈ Performance (before)
[Task] 41.2ms β blocks input for ~2.5 frames
ββ Animation Frame Fired
ββ Function Call frame()
ββ clearRect (0.3ms)
ββ 40k Γ drawParticle (38.6ms) β script + 2D raster on the main thread
ββ paint + composite deferred to end of task
[Event] pointermove β arrived at 3ms, dispatched at 41ms β INP = 44ms
After, the draw work is on a Worker track and the main thread only handles the tiny message, so the interaction is answered almost immediately:
Main thread (after) Worker thread (after)
[Event] pointermove 1.1ms [Task] Animation Frame Fired
ββ postMessage (0.2ms) ββ frame()
ββ 40k Γ drawParticle 15.9ms
Compositor thread
ββ Commit + Composite 2.4ms β worker frame picked up, no main-thread paint
Note the per-frame draw also got faster (38.6ms β 15.9ms) because the worker thread is not contending with layout, style, and framework work β it has the CPU to itself. The same long-task starvation, viewed per-frame, is easiest to keep an eye on with the long animation frames API.
The Fix: A Dedicated Rendering Worker
The migration is mechanical: transfer the surface, move the loop into a worker file, and reduce the main thread to a message forwarder. Here is the complete before/after.
Before β one file, everything on the main thread:
// main.js (before) β draws on the main thread every frame
const canvas = document.querySelector('#viz')
const ctx = canvas.getContext('2d') // context lives on the main thread
let pointer = { x: 0, y: 0 }
addEventListener('pointermove', (e) => { pointer = { x: e.clientX, y: e.clientY } })
function frame(t) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (let i = 0; i < 40000; i++) drawParticle(ctx, i, t, pointer) // blocks the main thread
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
After β two files. main.js transfers the surface and forwards input; render-worker.js owns the loop:
// main.js (after) β hand the surface to a worker, then stay out of the way
const canvas = document.querySelector('#viz')
const offscreen = canvas.transferControlToOffscreen() // main thread gives up the surface
const worker = new Worker('./render-worker.js', { type: 'module' })
worker.postMessage(
{ type: 'init', canvas: offscreen, dpr: devicePixelRatio },
[offscreen], // transfer list: zero-copy handoff, NOT a structured clone
)
// only tiny state deltas cross the boundary now β no per-frame pixels
addEventListener('pointermove', (e) => {
worker.postMessage({ type: 'pointer', x: e.clientX, y: e.clientY })
})
// render-worker.js (after) β owns the OffscreenCanvas and the render loop
let ctx, pointer = { x: 0, y: 0 }
onmessage = ({ data }) => {
if (data.type === 'init') {
ctx = data.canvas.getContext('2d') // OffscreenCanvas 2D context, on the worker
requestAnimationFrame(frame) // worker rAF β driven by the compositor's vsync
} else if (data.type === 'pointer') {
pointer = data // cheap message, no drawing on the main thread
}
}
function frame(t) {
const c = ctx.canvas
ctx.clearRect(0, 0, c.width, c.height)
for (let i = 0; i < 40000; i++) drawParticle(ctx, i, t, pointer) // runs on the worker thread
requestAnimationFrame(frame) // OffscreenCanvas 2D auto-commits at end of frame
}
A 2D OffscreenCanvas commits its contents automatically when the animation-frame callback returns β there is no manual commit() call in the current specification. The pointer handler still runs on the main thread (input must), but it now does a 0.2ms postMessage instead of 38ms of drawing, so the interaction lands within one frame. Because the drawing moved wholesale, the split of responsibilities across threads is clean:
Mapping the change back to the pipeline shows where each cost went:
| Pipeline phase | Constraint | Cost (before β after) |
|---|---|---|
| Script / draw calls | Single main-thread task queue | 38.6ms main β 0ms main (on worker) |
| Rasterization (2D) | Runs where the context lives | main thread β worker thread |
| Input dispatch | Waits behind the running task | 44ms INP β ~3ms INP |
| Commit / composite | Compositor thread, unchanged | ~2.4ms β ~2.4ms |
One caveat worth internalizing: OffscreenCanvas moves CPU rendering off the main thread, but it does not add GPU capacity. If your bottleneck is actually GPU rasterization or texture upload β common with large WebGL scenes β a worker will not conjure headroom that isnβt there, and you should first check whether you are near the GPU memory limits Chrome enforces on compositing. The win here is specifically about main-thread contention and interaction latency.
Verification
Confirm the migration actually offloaded the work rather than just relocating a bug:
Frequently Asked Questions
Does the worker need to be a separate file?
Yes β a Worker loads its own script, so the render loop must live in its own file (or a Blob URL built from a string). Using { type: 'module' } lets the worker use import, which is convenient for sharing your drawParticle code between the main-thread fallback and the worker without duplication.
Can I use WebGL or WebGPU in the worker, not just 2D?
Yes. Call getContext('webgl2') or getContext('webgpu') on the OffscreenCanvas inside the worker β the transfer pattern is identical. WebGL and WebGPU are actually the bigger wins, since their command submission is the heaviest main-thread cost when the canvas stays on the main thread.
What browsers support OffscreenCanvas?
Chrome and Edge have shipped it for years, Firefox supports it, and Safari added it in 16.4. Feature-detect with 'transferControlToOffscreen' in HTMLCanvasElement.prototype and fall back to a main-thread render loop where it is missing, so the page still works everywhere.
Moving to a worker did not help β why?
If the bottleneck is GPU rasterization or texture upload rather than main-thread script, offloading the CPU-side draw calls will not add GPU headroom. Check compositor and GPU cost first; OffscreenCanvas fixes main-thread contention and INP, not a saturated GPU.
How do I read pixels back or export the canvas as an image?
From inside the worker, call transferToImageBitmap() for a fast handoff to another context, or convertToBlob() to produce a PNG/JPEG for download or upload. Both run on the worker, so exporting a frame never blocks the main thread either.
Related Guides
- Off-Main-Thread Rendering β the parent topic covering worker-driven and off-thread rendering strategies.
- Observing Long Tasks with PerformanceObserver β catch the >50ms main-thread tasks that a busy canvas creates.
- Tracking Long Animation Frames β per-frame attribution to confirm the worker offload landed.
- Promoting Layers Safely with translateZ β how the compositor handles the surface the worker commits to.
- GPU Memory Limits in Chrome Compositing β check this when a worker offload does not relieve the frame budget.