CSS Houdini Paint Worklets for Cheap Visuals
A decorative animated background β a spinning conic ring, a shimmering gradient, a hand-drawn border β is stealing 5β7ms of your main thread every frame because the drawing logic runs as JavaScript that rewrites a style string and forces a Recalculate Style + Paint on each tick, and the paint phase reruns instead of the compositor reusing cached pixels.
This guide is part of Off-Main-Thread Rendering, inside the broader Compositing and GPU Acceleration work. The premise is simple: the visual does not need main-thread JavaScript at all. The CSS Paint API β the βpaint workletβ corner of CSS Houdini β lets you register a drawing function that the browser invokes in an isolated worklet scope, off the thread that runs your application code, and re-invokes only when a size or a declared custom property actually changes.
The symptom: a paint phase that reruns every frame
The classic offender is animation driven by mutating inline styles inside a requestAnimationFrame loop. It feels harmless β you are βjustβ assigning a string β but every assignment invalidates style for the element and schedules a repaint. Here is a minimal reproduction of a spinning progress ring:
const ring = document.querySelector('.ring');
let angle = 0;
function tick() {
angle = (angle + 4) % 360;
// rebuilds a multi-stop gradient string AND dirties style + paint every frame β all on the main thread
ring.style.background =
`conic-gradient(from ${angle}deg, #4456a8 0deg, #7a5c95 140deg, transparent 320deg)`;
requestAnimationFrame(tick);
}
tick();
Three costs stack up on the main thread for each of the 60 frames per second: the callback builds a fresh gradient string, the assignment triggers Recalculate Style on the element, and because the painted result differs, the element is added to the paint invalidation set and repainted. None of that work is shared with your application logic β it competes with it. When a data fetch resolves or a React commit lands in the same frame, the ringβs overhead is exactly what pushes the frame past 16.6ms.
The tell in a profile is that the Main lane is never idle: a thin, unbroken ribbon of scripting-plus-paint runs the entire time the animation is on screen, whether or not the user is doing anything. That is the workload we want to evacuate from the main thread entirely.
Where a paint worklet actually runs
The CSS Paint API introduces a second global environment: the PaintWorkletGlobalScope. It has no window, no document, and no access to the DOM β it exists only to hold registered painters and run them. You load a module into it with CSS.paintWorklet.addModule(url), and inside that module you call registerPaint(name, class). The browser keeps a registry that maps the paint name (ring) to your class definition.
When the browser needs to paint an element whose computed style contains background-image: paint(ring), it does not call back into your main-thread code. It looks up ring in the registry, constructs an instance in the worklet scope, and calls paint(ctx, geometry, properties). The ctx is a restricted 2D drawing context whose commands are recorded into a display list (a PaintRecord), not rasterized inline. The compositor then rasterizes that display list into tiles on its raster worker threads β the same off-main-thread raster path described in Why transform and opacity are GPU-accelerated.
Two design constraints fall out of this architecture. First, paint() must be stateless and idempotent β the browser may run it on more than one thread, in any order, and may cache or discard the result freely, so instance fields cannot be relied on to persist across calls. Second, invalidation is explicit. A painter declares static get inputProperties() listing the custom properties it reads; the browser re-invokes paint() only when the elementβs size changes or when one of those declared properties changes value. Everything else is served from cached tiles. That opt-in invalidation surface is the entire reason the effect becomes cheap β the browser knows precisely when it is allowed to skip the paint.
| Pipeline phase | Constraint | Cost per frame |
|---|---|---|
| Main-thread JS | Painter runs off-thread; no app JS | ~0ms |
| Recalculate Style | Only if an inputProperties value changes |
0ms when idle |
| Paint (worklet) | paint() invoked in worklet scope |
~0.3β0.6ms, off main |
| Raster + composite | Display list rasterized on worker threads | GPU-bound, off main |
Reading the DevTools trace
Record the animation in the Performance panel with Paint and Composite categories enabled, then compare the Main lane before and after. The inline-style version fills the Main thread with a repeating scripting-and-paint block; the worklet version leaves the Main lane almost empty and moves the drawing into the compositorβs paint-worklet task.
BEFORE β inline-style rAF loop
Main Thread ββββββββββββββββββββββββββββββββββββββββββββββ
Frame 1 [Task 6.4ms of 16.6ms budget]
ββ Animation Frame Fired 0.2ms
β ββ tick(): build conic-gradient string 2.1ms
ββ Recalculate Style (.ring) 1.9ms
ββ Pre-Paint / invalidate 0.4ms
ββ Paint .ring background 1.8ms <-- repaints every frame
Frame 2 ... identical, 60x/sec => ~384ms/sec of main-thread time
AFTER β paint worklet + animated custom property
Main Thread ββββββββββββββββββββββββββββββββββββββββββββββ
Frame 1 [Task 0.3ms]
ββ (registered custom-property tick; no user JS) <-- main thread idle
Compositor / Paint Worklet βββββββββββββββββββββββββββββββ
Frame 1
ββ Paint Worklet ring.paint() 0.5ms <-- off the main thread
ββ raster on worker threads GPU
Main thread free: ~15.8ms of the 16.6ms budget
The signature you are looking for is the disappearance of the Recalculate Style / Paint pair from the Main lane and the appearance of a Paint Worklet entry on the compositor. If you still see Recalculate Style on the main thread after switching, an inputProperties value is being written from main-thread JavaScript on every frame β drive it through a registered-property animation instead (shown below) so the change is scheduled off-thread. To turn this observation into a standing check, Observing long tasks with PerformanceObserver will flag the pre-worklet version as a stream of long tasks that vanishes after the fix.
The fix: draw it once in a worklet
The rewrite has three parts: a worklet module that draws the ring, a one-time addModule registration, and a CSS @property declaration so the angle is a typed custom property the browser can animate off-thread. The paint() function reads --ring-angle through the typed OM properties map, and because that property is the only thing in inputProperties, the browser re-invokes the painter only when the angle changes β which the CSS animation schedules on the compositor rather than in your JavaScript.
Before β main-thread rAF loop (repeated for reference):
const ring = document.querySelector('.ring');
let angle = 0;
function tick() {
angle = (angle + 4) % 360;
// dirties style + paint on the main thread every frame
ring.style.background =
`conic-gradient(from ${angle}deg, #4456a8 0deg, #7a5c95 140deg, transparent 320deg)`;
requestAnimationFrame(tick);
}
tick();
After β a paint worklet driven by an animated registered property:
// ring-painter.js (loaded into the PaintWorkletGlobalScope)
registerPaint('ring', class {
// paint() re-runs ONLY when size or one of these properties changes
static get inputProperties() { return ['--ring-angle']; }
paint(ctx, geom, props) {
const angle = parseFloat(props.get('--ring-angle')) || 0;
const cx = geom.width / 2;
const cy = geom.height / 2;
const r = Math.min(cx, cy) - 6;
ctx.lineWidth = 12;
ctx.lineCap = 'round';
ctx.strokeStyle = '#4456a8';
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + (angle * Math.PI) / 180);
ctx.stroke();
}
});
// main bundle β register the module exactly once, then never touch it again
if ('paintWorklet' in CSS) {
await CSS.paintWorklet.addModule('/ring-painter.js');
}
/* declaring the property makes it typed and animatable off the main thread */
@property --ring-angle {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
.ring {
width: 96px;
height: 96px;
background-image: paint(ring); /* drawn in the PaintWorkletGlobalScope, off the main thread */
animation: spin 1s linear infinite;
}
@keyframes spin {
to { --ring-angle: 360; }
}
There is no requestAnimationFrame, no string building, and no inline-style write left on the main thread. The angle advances through the CSS animation of a registered custom property, the browser re-invokes paint() in the worklet scope, and the compositor rasterizes the result. If the browser lacks the Paint API, the if ('paintWorklet' in CSS) guard skips registration and you fall back to a static background β supply one in the base rule so the element is never blank. This is the same βisolate the work, invalidate narrowlyβ discipline behind Minimizing paint areas with layer boundaries; here the boundary is a whole thread rather than a layer.
The same pattern generalizes well beyond a ring. Any decoration you would otherwise build from stacked gradients, a blurred box-shadow that repaints a large region, or a JavaScript-driven <canvas> overlay is a candidate: checkerboards, ripple fills, dashed focus rings, noise textures, tooltip pointers. Each becomes one paint() call whose cost the browser can cache and whose execution never lands on the thread your interactions run on β which is exactly what keeps input responsive, as covered across Compositing and GPU Acceleration.
Verification checklist
Frequently Asked Questions
Do paint worklets really run off the main thread?
The drawing does. Your paint() function executes in the PaintWorkletGlobalScope, which is separate from the window scope your application JavaScript runs in, and the resulting display list is rasterized on the compositorβs raster worker threads. The one thing that can pull work back onto the main thread is writing an inputProperties value from a per-frame main-thread script β drive that value through a registered @property animation instead so the change is scheduled off-thread.
How does the browser know when to repaint a worklet?
Invalidation is opt-in. A painter declares the custom properties it reads via static get inputProperties(), and the browser re-invokes paint() only when the elementβs size changes or one of those declared properties changes value. If your painter reads a property it did not declare, changes to that property will not trigger a repaint and the visual will appear stale β declaring the full dependency list is what makes caching both correct and cheap.
Is a paint worklet faster than an animated box-shadow?
Usually, for two reasons. First, box-shadow cannot be composited, so animating its blur or spread repaints a large region every frame; a worklet lets you draw a cheaper primitive that rasterizes faster. Second, the workletβs drawing runs off the main thread and its output is cached between changes. The honest caveat is that a worklet still repaints when its inputs change, so it wins most decisively against effects that are expensive to rasterize or that were being driven by main-thread JavaScript.
What happens in browsers without the CSS Paint API?
paint(name) resolves to nothing, so the element shows whatever other background layers you declared. Guard addModule with if ('paintWorklet' in CSS) and always ship a plain static background in the base rule as a fallback. Because the fallback is pure CSS, there is no JavaScript error and no layout difference β only the animated decoration is absent.
Why must the paint function be stateless?
The browser may instantiate your painter on multiple threads and call paint() in any order, caching or discarding results as it sees fit. Relying on instance fields to carry data between calls is therefore undefined β a value set on one frame may not exist on the next. Treat paint() as a pure function of (geometry, inputProperties) and it will render identically no matter how the engine schedules it.
Related Guides
- Off-Main-Thread Rendering β the parent topic covering every technique for keeping rendering work off the thread that runs your code.
- Why transform and opacity are GPU-accelerated β the compositor path that rasterizes your workletβs display list.
- Minimizing paint areas with layer boundaries β scope invalidation so repaints stay small.
- Observing long tasks with PerformanceObserver β confirm the main-thread long tasks disappear after the switch.