Off-Main-Thread Rendering
The main thread is the browser’s single most contended resource. It runs your JavaScript, resolves style, does layout, records paint, fires input handlers, and — unless you move work elsewhere — draws every pixel of every <canvas>. When one of those jobs runs long, everything queued behind it waits, and the frame you owed the compositor at the 16.6ms mark arrives late or not at all. Off-main-thread rendering is the set of platform features that let you take the heavy, self-contained rendering work — canvas draw loops, procedurally generated visuals, transform and opacity animation — and run it somewhere the main thread’s long tasks can’t stall it. This guide is part of Compositing and GPU Acceleration.
The three levers are OffscreenCanvas driven from a Web Worker, compositor-driven animation that never asks the main thread for a per-frame callback, and CSS Houdini paint worklets that generate images inside the rendering engine itself. Each moves a different kind of work off the critical path, and each carries a transfer or serialization cost you have to weigh against what it saves. Getting the trade wrong makes things slower, so this guide is as much about when not to as how to.
Diagnostic Checklist
Before reaching for a worker, confirm the symptom actually lives on the main thread. Record a trace in the Performance panel and walk this list:
- Long tasks that block input. In the Performance panel’s Main track, look for tasks longer than 50ms with a red corner flag. If a canvas render or data-to-pixels transform sits inside one, it’s a candidate to move.
- Dropped frames during a canvas animation. The Frames track shows red frames. If they line up with your
requestAnimationFramecanvas draw and the work is pure computation-to-pixels, anOffscreenCanvasworker removes it from the main thread entirely. - Animation that stops when the main thread is busy. If a
transformtransition visibly stutters while a network callback or React re-render runs, the animation is being driven by main-thread JS instead of the compositor. Recalculate Style/Paintrecurring per frame for a visual that never changes structurally — a gradient, a pattern, a texture. That repaint is a paint-worklet candidate.- INP regressions traced to a handler that also renders. Event Timing attributes the delay to a listener that does rendering work inline. Splitting rendering off the handler frees the input response.
- Main thread pegged at ~100% while the GPU sits idle. The compositor thread has headroom the main thread isn’t using; the work is on the wrong thread.
If none of these match — if your bottleneck is layout thrash, oversized layers, or paint area — off-main-thread rendering is the wrong tool and you should start with layer promotion or transform and opacity best practices instead.
Why the Main Thread Becomes the Bottleneck
The main thread processes one task to completion before starting the next — it has no preemption. A rendering job and an input handler cannot interleave; whichever the scheduler picked first runs uninterrupted. So a 40ms canvas redraw doesn’t merely cost 40ms of animation smoothness, it also delays every click, keypress, and scroll event that arrives during those 40ms. That is why a single expensive drawImage loop can wreck Interaction to Next Paint even when the page is otherwise idle.
The compositor thread is a separate story. It already runs off the main thread — it rasterizes layers and assembles frames on its own — which is why a promoted transform animation keeps moving during a main-thread stall. Off-main-thread rendering extends that principle: instead of relying only on the compositor for the two properties it can animate, you push whole categories of rendering work onto worker threads or into the paint pipeline, where the main thread’s long tasks can’t reach them.
Rendering with OffscreenCanvas in a Web Worker
A normal <canvas> is bound to the DOM, so its 2D or WebGL context can only be driven from the main thread. OffscreenCanvas breaks that binding. You call transferControlToOffscreen() on the DOM canvas — which hands its backing surface to an OffscreenCanvas object — then postMessage that object to a worker with a transfer list. From then on the worker owns the drawing surface. It can run a full requestAnimationFrame loop, issue drawImage / WebGL calls, and the results appear in the on-screen canvas without the main thread touching a single pixel.
The transfer is a move, not a copy. After transferControlToOffscreen() the main-thread canvas is neutered — its context is null and any draw call throws — because ownership genuinely left the main thread. That’s the whole point: there is no shared surface to synchronize, so there’s no cross-thread contention on the render itself. The only cost is the one-time transfer plus whatever data you keep sending the worker each frame.
// main.js
// ❌ BEFORE: 2D context lives on the main thread; every draw competes with input
const canvas = document.querySelector('#viz')
const ctx = canvas.getContext('2d') // context bound to main thread
function frame() {
drawExpensiveScene(ctx) // 30–40ms task blocks input + frames
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
// main.js
// ✅ AFTER: hand the surface to a worker; main thread never draws again
const canvas = document.querySelector('#viz')
const offscreen = canvas.transferControlToOffscreen() // canvas is now neutered on main
const worker = new Worker('/viz-worker.js')
worker.postMessage({ canvas: offscreen }, [offscreen]) // transfer list = zero-copy move
// viz-worker.js
onmessage = ({ data }) => {
const ctx = data.canvas.getContext('2d') // context now owned by the worker
const frame = () => {
drawExpensiveScene(ctx) // runs on the worker's own thread
requestAnimationFrame(frame) // rAF exists in workers via OffscreenCanvas
}
requestAnimationFrame(frame)
}
The critical subtlety is the second argument to postMessage: [offscreen] is the transfer list. Without it, the structured-clone algorithm would try to copy the object and fail. With it, the OffscreenCanvas handle is transferred by reference in constant time. The same rule applies to any large payload you send per frame — an ArrayBuffer of vertex data should be transferred, not cloned, or you pay a serialization tax every frame. The full setup, including WebGL contexts and resize handling, is walked through in rendering with OffscreenCanvas in a Web Worker.
Moving Animation Work off the Main Thread
Not all off-main-thread rendering needs a worker. The compositor thread already runs independently, and for transform and opacity you don’t move code off the main thread — you avoid running code per frame at all. A requestAnimationFrame loop that sets el.style.transform each tick is main-thread animation: the callback runs on the main thread, so a long task freezes it. Declaring the same motion through the Web Animations API or a CSS transition hands it to the compositor, which keeps ticking through main-thread stalls.
The rule for keeping an animation off the main thread is strict: animate only transform and opacity, and make sure nothing forces the animation to fall back to the main thread. Compositor animations get commuted back to the main thread if the animated element’s transform depends on layout (percentage transforms on an auto-sized box are fine, but some cases aren’t), if you attach a requestAnimationFrame reader, or if you animate a property the compositor can’t handle. The full decision table and the FLIP pattern are in moving animation work off the main thread and the property-level detail is in animating transforms without layout thrash.
// ❌ BEFORE: main-thread animation — a long task on the main thread freezes it
let start
function step(now) {
start ??= now
const p = Math.min((now - start) / 400, 1)
box.style.transform = `translateX(${p * 240}px)` // written from main-thread JS each frame
if (p < 1) requestAnimationFrame(step)
}
requestAnimationFrame(step)
// ✅ AFTER: compositor-driven — runs off the main thread, survives long tasks
box.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(240px)' }],
{ duration: 400, easing: 'ease-out' },
) // handed to the compositor; no per-frame main-thread callback
CSS Houdini Paint Worklets for Cheap Visuals
A paint worklet is the third route off the main thread, and it targets a different cost: repaint. When you draw a gradient, a checkerboard, a ripple, or a placeholder shimmer with a DOM element or a canvas, the browser repaints it on the main thread whenever it invalidates. A registered paint worklet moves that generation into the rendering engine’s paint phase. You register a class whose paint(ctx, size, props) method draws into a context very similar to canvas 2D, then reference it with background: paint(myPainter). The engine calls it during paint — on its own worklet thread, isolated from your main-thread JS — and caches the result until an input property changes.
The win is twofold. First, the drawing code runs off the main thread, so a complex generated background doesn’t add to your main-thread paint time. Second, the output is cached and only regenerated when a declared custom property changes — and if you register that property with inherits: false and animate it, the worklet can repaint in response to a compositor-friendly value change instead of a full main-thread style recalculation. The catch is support: paint worklets ship in Chromium but not Firefox or Safari, so they must be a progressive enhancement with a static fallback. The full pattern, including registerProperty and animatable custom properties, is in CSS Houdini paint worklets for cheap visuals.
// ❌ BEFORE: a shimmering placeholder animated by repainting a gradient on the main thread
function shimmer(el, t) {
const x = (t % 1200) / 1200 * el.offsetWidth
el.style.background = // triggers main-thread paint each frame
`linear-gradient(90deg, #eef1f7, #ffffff ${x}px, #eef1f7)`
requestAnimationFrame((now) => shimmer(el, now))
}
// ✅ AFTER: a paint worklet draws the shimmer off the main thread, driven by a custom prop
// shimmer-worklet.js
registerPaint('shimmer', class {
static get inputProperties() { return ['--shimmer-pos'] } // repaint only when this changes
paint(ctx, size, props) {
const x = parseFloat(props.get('--shimmer-pos')) * size.width
const g = ctx.createLinearGradient(0, 0, size.width, 0)
g.addColorStop(0, '#eef1f7'); g.addColorStop(x / size.width, '#ffffff'); g.addColorStop(1, '#eef1f7')
ctx.fillStyle = g
ctx.fillRect(0, 0, size.width, size.height) // runs on the worklet thread
}
})
/* main-thread does no paint work; the worklet output is cached and composited */
.placeholder { background: paint(shimmer); }
@property --shimmer-pos { syntax: '<number>'; initial-value: 0; inherits: false; }
When Moving Work Off the Main Thread Actually Helps
Every off-main-thread technique adds a boundary, and boundaries cost something to cross. postMessage between the main thread and a worker either transfers (constant time, for ArrayBuffer, OffscreenCanvas, ImageBitmap, MessagePort) or structured-clones (proportional to payload size, for everything else). Send a fresh 5MB typed array by clone every frame and you’ve replaced a render cost with a serialization cost that may be worse. The decision is always: does the work moved off the main thread exceed the cost of getting it there?
The heuristic that holds up in practice: move work off the main thread when it is self-contained (needs no synchronous DOM or layout reads), expensive (tens of milliseconds, or it recurs every frame), and transferable (its inputs and outputs cross the boundary by reference, not by deep copy). A WebGL particle system driven by a small uniform buffer is an ideal candidate. A canvas that must read getBoundingClientRect of DOM elements every frame is a poor one, because you’d re-introduce main-thread synchronization. When the work fails the test, the fix is usually on the main thread anyway — reducing paint area, promoting fewer layers, or cutting forced synchronous layout.
| Pipeline phase | Constraint | Cost of moving off-main |
|---|---|---|
| Canvas 2D / WebGL draw | Must own the surface (neutered on main) | One-time transferControlToOffscreen; per-frame transfers only |
| Per-frame data upload | Structured clone vs transfer | O(1) if transferred; O(n) if cloned |
transform / opacity animation |
Compositor-eligible only | Zero — no code moves, no per-frame JS |
| Generated background image | Chromium-only worklet | Cached; regenerates only on input-property change |
| DOM/layout-dependent draw | Needs main-thread reads | Not movable — synchronization cancels the win |
Step-by-Step Fix Procedure
- Capture the baseline. In the Performance panel, record while the jank reproduces. Note the Main track’s longest task, the number of red frames in the Frames track, and the INP value in the footer. Write these down — they are your before/after evidence.
- Attribute the cost. Expand the long task. If the self time sits in your canvas draw or a gradient/pattern generator, it’s movable. If it’s in
LayoutorRecalculate Style, stop — this guide’s tools won’t help; go to layer promotion and composition. - Pick the lever from the decision tree. Transform/opacity motion → compositor animation. Canvas/WebGL scene →
OffscreenCanvasworker. Generated CSS image → paint worklet. - Move the work, minding the boundary. For a worker,
transferControlToOffscreen()once and transfer (never clone) per-frame buffers. For an animation, replace the rAF loop withelement.animateor a CSS transition ontransform/opacityonly. - Verify on the compositor. Re-record. In the Performance panel enable the Rendering tab’s Frame Rendering Stats; confirm the animation frames now appear on the Compositor track, not the Main track. For a worker, confirm a new thread lane appears in the trace with the draw work in it, and the Main track is quiet.
- Check you didn’t regress input. Compare INP before and after. Moving render work off the main thread should lower it; if a per-frame clone crept in, the new serialization cost shows up as main-thread
postMessagetime.
[Before] Performance panel — Main track
└─ Task (41.2ms) ← RED long task
└─ Animation Frame Fired (39.8ms)
└─ drawExpensiveScene (38.1ms) ← canvas render on main thread
INP: 240ms Dropped frames: 22
[After] Performance panel
├─ Main track: quiet (input handlers < 5ms)
└─ Worker track:
└─ Animation Frame Fired (38.4ms)
└─ drawExpensiveScene (37.9ms) ← same work, off the main thread
INP: 88ms Dropped frames: 1
Edge Cases: React, Vue, and Next.js
Frameworks add their own render loop on top of the browser’s, so the boundary needs care. In React, create the worker and call transferControlToOffscreen() inside a useEffect with an empty dependency array — never during render, which can run twice under StrictMode and would throw on the second transferControlToOffscreen because the canvas is already neutered. Store the worker in a ref and terminate it in the cleanup function. Because the canvas surface now lives in the worker, React must never re-mount that <canvas> (a changing key remounts it and orphans the worker’s surface), so keep the element stable and let the worker own everything below it.
// ✅ React: transfer once, own the worker in a ref, tear down on unmount
function Viz() {
const canvasRef = useRef(null)
const workerRef = useRef(null)
useEffect(() => {
const offscreen = canvasRef.current.transferControlToOffscreen() // once, post-mount
const worker = new Worker(new URL('./viz-worker.js', import.meta.url))
worker.postMessage({ canvas: offscreen }, [offscreen]) // transfer, not clone
workerRef.current = worker
return () => worker.terminate() // StrictMode-safe cleanup
}, [])
return <canvas ref={canvasRef} width={640} height={480} /> // stable, never re-keyed
}
For compositor animation, prefer the Web Animations API over animating through React state — driving transform from useState re-renders the component tree every frame and drags the work back onto the main thread, the exact failure covered in React concurrent rendering vs forced reflow. In Vue, the same rules apply: set up the worker in onMounted, tear down in onUnmounted, and animate with the Web Animations API rather than binding transform to a reactive ref that a watcher updates per frame.
Next.js and any SSR framework add a hard constraint: OffscreenCanvas, Worker, and CSS.paintWorklet do not exist on the server. Guard every reference behind a client boundary — 'use client' plus a typeof window !== 'undefined' check, or dynamic import with { ssr: false }. Paint worklets need CSS.paintWorklet.addModule('/shimmer-worklet.js') called once on the client after hydration, and the worklet file must be a real static asset served from public/, not a bundled module, because the browser fetches it by URL. Always ship a plain CSS fallback for the non-Chromium engines that ignore paint().
Metric Targets
| Metric | Target | How measured |
|---|---|---|
| Longest main-thread task during render | < 50ms | Performance panel, Main track |
| Canvas draw location | Worker track, not Main | Performance panel thread lanes |
| Animation frame location | Compositor track | Rendering → Frame Rendering Stats |
Per-frame postMessage cost |
< 1ms (transfer, not clone) | Performance panel, postMessage events |
| INP after offloading | < 200ms | Event Timing API / footer |
| Dropped frames during animation | ~0 | Frames track |
| Paint-worklet repaints | Only on input-property change | Rendering → Paint flashing |
In This Topic
- Rendering with OffscreenCanvas in a Web Worker — transfer a canvas surface to a worker and run the full draw loop off the main thread.
- Moving Animation Work off the Main Thread — drive
transform/opacityon the compositor so animation survives long tasks. - CSS Houdini Paint Worklets for Cheap Visuals — generate backgrounds and patterns in the paint phase with cached, off-main-thread output.
Frequently Asked Questions
Does OffscreenCanvas make my rendering faster?
Not intrinsically — the same drawing work takes about the same time. What it changes is where that time is spent. Moving a canvas draw loop into a worker with OffscreenCanvas takes the work off the main thread, so it no longer blocks input handling, style, layout, or frame delivery. Your INP and dropped-frame counts improve even though the raw draw cost is unchanged. If the draw itself is the bottleneck and nothing else contends for the main thread, offloading buys little.
What is the difference between transferring and cloning in postMessage?
postMessage copies its payload with the structured-clone algorithm by default, which is O(n) in the data size. The optional second argument is a transfer list: objects in it (ArrayBuffer, OffscreenCanvas, ImageBitmap, MessagePort) are moved by reference in constant time and become unusable on the sending side. For per-frame data, always transfer — cloning a large buffer every frame can cost more than the render you moved off the main thread.
Why does my compositor animation still stutter during a long task?
It was probably commuted back to the main thread. The compositor only runs transform and opacity animations, and only when nothing forces a main-thread dependency — animating a non-compositable property, reading the element’s geometry in a requestAnimationFrame loop, or certain layout-dependent transforms all pull the animation back. Confirm in the Performance panel that the frames appear on the Compositor track, not the Main track, and audit the animation for any main-thread reads.
Are CSS Houdini paint worklets safe to use in production?
Only as a progressive enhancement. Paint worklets ship in Chromium-based browsers but not Firefox or Safari, where a background: paint(...) declaration is simply ignored. Always provide a static fallback (a plain color or gradient) so non-supporting engines render something sensible, and feature-detect with 'paintWorklet' in CSS before calling addModule. Used that way they reliably move generated-image paint work off the main thread on the browsers that support them.
Can I use OffscreenCanvas without a Web Worker?
Yes. You can create an OffscreenCanvas on the main thread and use it as an in-memory drawing surface — useful for pre-rendering an ImageBitmap you then blit onto a visible canvas. But that keeps the work on the main thread, so it does nothing for frame budget. The off-main-thread benefit only appears when you transferControlToOffscreen() and drive the surface from a worker.
Related Guides
- Compositing and GPU Acceleration — the parent section on layers, rasterization, and frame assembly.
- Rendering with OffscreenCanvas in a Web Worker — the full worker-canvas setup with transfer and resize handling.
- Moving Animation Work off the Main Thread — keeping
transform/opacitymotion on the compositor. - Animation Performance Patterns — the compositor-only property rules that underpin off-main-thread animation.
- Transform and Opacity Best Practices — why only these two properties reach the compositor.