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 per-frame main-thread loop that repaints the ring A rAF callback rebuilds a gradient string, forcing recalculate style and paint on the main thread, looping sixty times per second. Main thread β€” one animation frame rAF callback Rebuild gradient Recalculate Style Paint .ring loop repeats 60x / second β€” roughly 360ms per second of main-thread time

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.

How a paint worklet is loaded and invoked across scopes The main thread loads a module once; the paint function runs in the isolated worklet scope and feeds a display list to the compositor raster threads. Main thread PaintWorkletGlobalScope Compositor raster addModule(painter) element: paint(ring) registerPaint('ring') stored in registry paint(ctx, geom, props) display list to tiles rasterized off main load once on invalidation

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.

Main-thread occupancy before and after the worklet Before, every frame fills the main thread with work; after, the main thread is idle and drawing moves to the compositor lane. Before β€” Main thread lane frame 1 frame 2 frame 3 frame 4 frame 5 busy After β€” Main thread lane idle β€” no scripting, no paint After β€” Compositor / worklet lane paint()

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.

When the browser re-invokes the paint worklet A decision path showing that paint reruns only on size change or a declared input property change, otherwise cached tiles are reused. Frame produced element uses paint(ring) size OR --ring-angle changed? re-invoke paint() off main, then raster reuse cached tiles zero paint cost yes no

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.