Moving Animation Work off the Main Thread

Your animation freezes for 180ms whenever a data fetch resolves and re-renders a list, even though the moving element only changes transform β€” the symptom is a main-thread-driven animation whose per-frame JavaScript sits behind a long task in the event loop, never reaching the Composite phase in time. This guide is part of Off-Main-Thread Rendering, itself a section of Compositing and GPU Acceleration, and it focuses on the mechanics of getting motion off the main thread entirely so that unrelated work can never stall it.

The distinction that matters here is not which property you animate β€” that is covered in Transform and Opacity Best Practices β€” but which thread produces each frame. An animation can use only compositor-friendly properties and still jank if you drive it with a requestAnimationFrame loop, because that callback is queued on the same thread as your React reconciliation, your JSON parsing, and your click handlers.

Where the Frame Is Produced

A browser tab runs animation on two very different execution contexts. The main thread owns the DOM, runs your scripts, and executes requestAnimationFrame callbacks. The compositor thread owns the layer tree and can re-draw promoted layers with a new transform matrix or opacity value without asking the main thread for anything. When you hand an animation to the compositor β€” via CSS transitions, CSS keyframes, or the Web Animations API restricted to transform/opacity β€” the compositor advances it from its own timeline. A 200ms parseAndRender() long task on the main thread then has no effect on the moving pixels.

The failure mode is subtle because both approaches look correct in code. Both mutate transform. The difference is where the interpolation runs each frame. A requestAnimationFrame driver reads the clock, computes a value, and writes a style property inside a callback that the event loop schedules on the main thread; a compositor animation asks the browser to do that interpolation for you on a thread you never share with application code. When your profiler shows a moving element and a 180ms task on the same lane, the animation is on the wrong thread no matter how cheap the property looks.

This is worth internalizing because most teams reach for transform first β€” following advice about compositor-friendly properties β€” and then conclude the advice was wrong when jank persists. The property choice removes Layout and Paint from the per-frame cost; it does not remove the animation from the main thread’s task queue. Both fixes are necessary, and this guide is about the second one.

Main thread versus compositor thread animation ownership A rAF-driven animation is blocked behind a long task on the main thread, while a compositor-driven animation advances on its own thread unaffected. Main thread (event loop) parseAndRender (180ms) click handler rAF tick (queued, late by 180ms) Result: motion stalls until the long task drains Compositor thread (independent timeline) frame n frame n+1 frame n+2 frame n+3 Result: transform matrix updated every 16.6ms, main thread irrelevant

Reproducing the Stall

The following snippet animates a card with a requestAnimationFrame loop that only ever touches transform. It is compositor-friendly but not compositor-driven: the interpolation math runs in JavaScript, on the main thread, once per frame. Trigger a long task alongside it and the motion visibly hitches.

const card = document.querySelector('.card')
const start = performance.now()

function tick(now) {
  const t = Math.min((now - start) / 400, 1)
  card.style.transform = `translateX(${t * 240}px)` // interpolation runs on the main thread
  if (t < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)

// Simulate an unrelated long task 100ms in β€” a fetch resolving, a store update, a re-render
setTimeout(() => {
  const end = performance.now() + 180
  while (performance.now() < end) {} // blocks the event loop; rAF ticks queue behind it
}, 100)

During the 180ms block, no tick runs, so the card sits frozen mid-slide, then jumps to catch up. The property is fine; the driver is on the wrong thread.

The Event-Loop Queue That Traps You

requestAnimationFrame callbacks are not a separate priority lane. They are serviced during the rendering opportunity that follows the current task, and only after that task has fully returned to the event loop. A synchronous 180ms block is one task. Until it returns, the browser cannot start a rendering opportunity, so every queued rAF callback β€” and every style/layout/paint step that would follow β€” waits. The data structure is a single ordered task queue per event loop, drained one entry at a time.

Compositor animations escape this because they are registered once with the compositor as an animation timeline plus a set of keyframes. After that registration, the main thread’s task queue is irrelevant to them. The compositor samples its own clock, computes the interpolated matrix on the GPU-adjacent thread, and never re-enters the event loop for subsequent frames. This is the same mechanism that lets a promoted layer scroll smoothly while JS is busy, discussed in layer promotion and composition.

It helps to be precise about what crosses the thread boundary and when. Registration is a one-time message: the main thread serializes the keyframes and easing into an animation record and posts it to the compositor. From that point the compositor holds the entire animation state β€” start time, duration, current playback rate, and the interpolation curve β€” so each subsequent frame is a pure sample against its own monotonic clock. Nothing about frame n+1 depends on the main thread having returned from frame n’s work. The only events that travel back to the main thread are lifecycle callbacks (finish, cancel), and those are queued as ordinary tasks, so they wait politely behind your long task without affecting the pixels already on screen.

The table below maps each per-frame stage to the thread that owns it and the cost you pay when the animation is driven the wrong way.

Pipeline phase Owning thread (off-main path) Constraint Per-frame cost when driven by rAF
Interpolate value Compositor Must be transform / opacity 0.2–0.8ms of JS on the main thread
Recalculate Style Skipped entirely Style dirty only if a class toggles 1–3ms after a blocking task drains
Layout Skipped entirely Any geometric prop forces it back 8–24ms, blocks the frame
Composite Layers Compositor Layer must survive on GPU 0.9ms on compositor / 2–4ms on main

The rows that read β€œSkipped entirely” are the whole point: an off-main-thread animation never touches Style or Layout after registration, which is precisely why a busy main thread cannot reach it.

Decision path for moving an animation off the main thread A decision tree that routes an animation to the compositor when it only needs transform and opacity, and to a worker or scheduler otherwise. Does it change only transform / opacity? Yes: hand to compositor (WAAPI / CSS keyframes) No: per-frame JS needed move compute off-thread Survives long tasks, zero main-thread cost Worker + OffscreenCanvas, or scheduler.yield to unblock the loop yes no

Reading the Trace

Record the reproduction in the Performance panel and the two thread lanes tell the whole story. The main-thread lane shows the 180ms task as one unbroken block, and the animation’s Recalculate Style / Composite Layers entries pile up immediately after it β€” all catching up in a single frame. The compositor lane, by contrast, keeps producing frames on its own cadence.

[Main thread]
β”œβ”€ Task: parseAndRender ─────────────── 180.4ms   ← blocks the event loop
β”‚   └─ (no rendering opportunity can start here)
β”œβ”€ Animation Frame Fired                 0.3ms     ← first rAF after the block
β”‚   └─ Recalculate Style                 1.9ms     ← 11 queued ticks collapse into one
β”œβ”€ Composite Layers                      2.1ms
└─ Frame                                 183.9ms   ← DROPPED (target 16.6ms)

[Compositor thread β€” same recording, WAAPI version]
β”œβ”€ Composite Layers   16.6ms apart       0.9ms
β”œβ”€ Composite Layers   16.6ms apart       0.9ms     ← advances during the 180ms block
β”œβ”€ Composite Layers   16.6ms apart       0.9ms
└─ (main-thread task never appears in this lane)

The tell is the single fat Frame bar at 183.9ms on the main-thread version versus the steady 0.9ms compositor bars on the Web Animations API version. If your animation shows the former, its driver is on the main thread regardless of which property it mutates.

The Fix: Register the Animation, Don’t Drive It

The correct move is to hand the keyframes to the browser once and let the compositor sample them. The Web Animations API does exactly this for transform/opacity, and the returned Animation object still gives you finished, cancel(), and playbackRate for control. For genuinely per-frame JavaScript work β€” a canvas particle system, a physics simulation β€” the analogous move is to run the computation in a Web Worker with OffscreenCanvas, or at minimum to break the blocking task with scheduler.yield() so rAF callbacks can interleave.

// BEFORE β€” compositor-friendly property, but driven on the main thread
const card = document.querySelector('.card')
const start = performance.now()
function tick(now) {
  const t = Math.min((now - start) / 400, 1)
  card.style.transform = `translateX(${t * 240}px)` // per-frame JS on the main thread
  if (t < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)

// AFTER β€” registered once, advanced by the compositor's own timeline
const card = document.querySelector('.card')
const animation = card.animate(
  [{ transform: 'translateX(0)' }, { transform: 'translateX(240px)' }],
  { duration: 400, easing: 'ease-out' }, // compositor samples this without re-entering the event loop
)
// A 180ms long task now cannot stall the motion β€” no rAF callback is on the critical path.
animation.finished.then(() => card.classList.add('settled'))

If you truly need per-frame math (say the target position depends on live pointer input that can’t be expressed as static keyframes), keep the driver but stop it from ever sharing a task with heavy work β€” offload the heavy work instead. The pattern for keeping input-linked reads cheap is in debouncing scroll-driven layout reads, and passive listeners keep the input path itself off the critical work, covered in passive listeners for smooth scroll.

// AFTER (per-frame JS unavoidable) β€” move the compute off the main thread
// worker.js
onmessage = (e) => {
  const positions = simulate(e.data.frame) // heavy physics runs off the main thread
  postMessage(positions)
}

// main.js β€” the worker computes, the compositor paints via OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen]) // rendering handed to the worker thread

One caveat: the compositor will silently fall back to the main thread if the animated element cannot stay on its own layer β€” for example if a non-compositable property is also animating, or GPU memory is exhausted. Those limits are enumerated in hardware acceleration limits, and safe layer promotion is covered in promoting layers safely with translateZ. A layout-triggering property sneaking into the keyframes drops you right back onto the main thread, as detailed under forced synchronous layouts.

Before and after thread ownership of the animation driver Before, the driver, interpolation and composite all sit on the main thread; after, only registration touches the main thread and the compositor owns every frame. Before: rAF driver After: WAAPI registration rAF callback interpolate in JS write transform every frame, main thread element.animate() once compositor samples off thread

Verification Checklist

Frequently Asked Questions

Why does my transform animation still jank if transform is a compositor property?

Because animating a compositor-friendly property is not the same as running the animation on the compositor thread. A requestAnimationFrame loop that writes transform still computes the value in JavaScript on the main thread every frame, so a long task blocks it. Register the keyframes with element.animate() or a CSS transition so the compositor advances the animation from its own timeline.

Does the Web Animations API always run off the main thread?

Only when every animated property is compositable (transform, opacity, and a few filters) and the element can hold its own layer. If a keyframe touches a layout- or paint-triggering property, or the layer is demoted because of GPU memory pressure, the browser falls back to advancing the animation on the main thread. Keep the keyframes to transform and opacity to guarantee the off-thread path.

When should I use a Web Worker instead of the Web Animations API?

Use the Web Animations API whenever the motion can be expressed as static keyframes of transform/opacity β€” it needs no worker at all. Reach for a Web Worker plus OffscreenCanvas when you have genuinely per-frame computation that cannot be pre-baked into keyframes, such as a canvas particle system or a physics simulation, so the compute stays off the main thread even though it is not a CSS property animation.

How do I confirm in DevTools that an animation is off the main thread?

Record in the Performance panel and look at the Compositor thread lane. An off-main-thread animation produces steady Composite Layers entries there even while the main thread lane is busy with a long task. If instead the animation’s style and composite work appears only in the main thread lane, bunched right after a task block, it is being driven on the main thread.