The Animation Wasn't Where the Time Went

A chart redraws at 5 Hz while its y-axis rescales. Animating the rescale as a composited transform, instead of re-rendering every frame, cut main-thread work by roughly two-thirds. The costs I braced for never fired. The redraw itself dominated.

  • performance
  • react
  • web-animations-api
  • svg
  • rendering

A chart that never holds still has two clocks. New data lands several times a second and the plot redraws: in the rig I will use here, about 200 points and a fitted curve at 5 Hz. Separately from that, the y-axis occasionally rescales when the reader changes what it spans, and I want that rescale to glide rather than jump.

An animation chart with about 200 points and a fitted curve at 5 Hz

A chart with 200 dots and a fitted curve.

So two things happen on the same element at once: a transform animation (the rescale) and a high-frequency content update (the redraw). The instinct is that the hard part is keeping the animation cheap while the redraw keeps firing. I built a rig to measure it, and the instinct pointed at the wrong thing. A transform, handed to the compositor, stays cheap even while the chart redraws at 5 Hz, and the two layout costs I braced for came to nothing. The bill was the redraw itself.

The naive rescale: interpolate on the main thread

The obvious way to animate a range change is to interpolate it yourself. On each requestAnimationFrame, compute an intermediate range and re-render the chart at it. It works, and it looks smooth.

It also runs the entire animation on the main thread. Every frame recomputes the scales and rebuilds the plot, which is the same redraw cost you already pay at 5 Hz, now paid at 60 Hz for the length of the transition. For that window the animation competes for the main thread with the very updates it is animating over.

Measurements come from a production-built Chrome rig reproducing the workload of the live demo (about 200 moving nodes and a transform rescale, no CPU throttling). Each figure below is total main-thread time accumulated over a five second trace, not a per-frame or per-second number:

5 s window · ~200 nodes · production build · no throttle
Scripting Rendering Painting ~main thread
rAF (per-frame) 43 ms 48 ms 16 ms ~107 ms
WAAPI transform 17 ms 19 ms 4 ms ~40 ms

The fix: a composited transform, on an element that can take it

The rescale is, geometrically, a pure transform. Re-render the content at the new range immediately, then animate a translateY and scaleY from the value that makes the new layout look like the old range back to identity. Two things have to hold for that to stay off the main thread, and the second is easy to miss.

First, animate transform only, and let React commit once. The keyframes touch nothing but transform; React renders the chart a single time, at the new range, then stays out of the transition. That alone is most of the drop from 107 ms to 40 ms: the main thread stops rebuilding the chart sixty times a second.

Second, animate an element the compositor will actually promote. Chrome runs transform animations on the compositor for HTML and replaced elements, but not for SVG child elements. Put the animation on an inner <g> and it falls back to per-frame main-thread layout, silently, with no warning. So the chart is two layers: a static base SVG for the axes and grid, and an interior layer for the dots, curve and cursor, wrapped in an HTML <div>. The transform animates the <div>.

the compositor handoff
// yRange changed. The interior is already re-rendered at the new range;
// animate the wrapper from "looks like the old range" to identity.
const sy = (newMax - newMin) / (oldMax - oldMin);
const ty =
MARGIN.top * (1 - sy) + (innerH * (oldMax - newMax)) / (oldMax - oldMin);
// `layer` is the HTML wrapper around the interior SVG, pinned over the
// base with `inset-0` and `transform-origin: 0 0`, so the maths is the
// same as it would be in the SVG view-box.
layer.animate(
[
{ transform: `translateY(${ty}px) scaleY(${sy})` },
{ transform: "translateY(0px) scaleY(1)" },
],
{ duration: 300, easing: "cubic-bezier(.4, 0, .2, 1)", fill: "forwards" },
);

The main thread hands the transform keyframes to the compositor, then carries only the 5 Hz redraw

The compositor handoff.

With both in place the main thread hands off once and goes quiet: the two keyframes interpolate on the compositor’s own thread while the main thread carries only the 5 Hz baseline. The picture on screen is identical; what dropped is main-thread work, not a jump in frame rate.

Confirm it composited; the Animations lane will not tell you. This is the step that is easy to skip, because a transform animation shows up in DevTools’ Animations lane whether or not it is on the compositor. The real check is the animation event in a trace: its compositeFailed field reads zero when the animation is composited, and a non-zero bitmask of reasons when it is not. The inner <g> reports a non-zero value; the HTML wrapper reports zero. Skip the check and it is easy to ship a “compositor” animation that is quietly running on the main thread, costing a little layout on every frame.

DevTools performance trace of the rAF approach, main thread busy throughout

rAF: Scripting 43, Rendering 48, Painting 16 ms; ~107 ms on the main thread.

DevTools performance trace of the WAAPI approach, main thread mostly quiet

WAAPI: Scripting 17, Rendering 19, Painting 4 ms; ~40 ms.

The absolute figures are small because this workload is modest. What matters is where the work lands and how it scales: on the main thread, cost grows with redraw frequency and node count. On the compositor, the transform is largely independent of both.

Two layout costs that didn’t bite

Both are versions of the same worry: the browser quietly redoing layout, the pass that works out where every element sits, on each one of those 5 Hz frames. On a hot path that is exactly the kind of cost that hides. I built the rig partly to catch them. Neither fired.

The forced reflow. Layout is normally batched: your code can change a hundred things and the browser works out the new geometry once, just before it paints. But the moment your code reads a geometry value back (an element’s position or size), the browser has to stop and compute layout right then. That synchronous, on-demand layout is a forced reflow, and on a hot path it is a classic offender. A CSS-transition build of the rescale includes one: it commits the start state with a geometry read before the transition begins.

It does not cost, and the reason is specific. The thing being animated is a composited transform, which the browser resolves without touching layout. So when the code forces the read, the transform has invalidated no layout; the read just pulls forward the layout the redraw would do that frame anyway. It reorders work, it does not add it. The two builds come out indistinguishable: 2.5 ms of layout against 2.2 ms over five seconds. A forced reflow only bites when it makes layout run twice (read, change something that invalidates layout, read again). This one runs it once.

Expected path runs layout twice; with a transform, layout runs once, so the read reorders work rather than adding it

Forced reflow: expected two passes, measured one.

The moving reference box. A transform scales and moves an element around a reference box. Tie that box to the element’s own content and, next to a constant redraw, it looks like a trap: a box that wraps content shifting every frame shifts too, and the browser might re-resolve the transform against it each time, layout on every redraw. The HTML wrapper closes this off by construction. Its reference box is the chart rectangle, pinned by inset-0; it does not track the dots moving inside it, so there is nothing to re-resolve.

Where the cost actually is

With the animation handed off and the two suspected costs ruled out, the trace points somewhere flat and unglamorous. The dominant per-redraw cost is setAttribute and Recalculate style, together around a third of main-thread time: mutating a couple of hundred SVG nodes’ geometry five times a second, and the style invalidation that follows. It is identical across every build, because it has nothing to do with how the rescale is animated.

Bottom-up trace with Recalculate style and setAttribute as the top entries

One redraw, bottom-up: setAttribute and Recalculate style dominate, layout ~5% (‘Profiling overhead’ is DevTools, not the page).

The lever, then, is the redraw, not the transition: fewer nodes, batched attribute writes, or a different rendering substrate such as canvas. Not the animation, which is free once it is on the compositor, and not the reference box, which was never doing what I thought.

What I would take from this

Compositor-only is the right instinct, and it paid off: the rescale belongs on the compositor, and putting it there, on an element that could take it, dropped main-thread work from about 107 ms to 40 ms over the trace. Two qualifications matter as much as the headline. First, “animate transform and opacity” is necessary, not sufficient: the element has to be one the compositor will promote, and an SVG child is not, so confirm it with the compositeFailed flag rather than trusting the Animations lane. Second, the costs I was most sure about either reorder into nothing or never fire, and the one that dominated is the plain one I had stopped looking at: SVG mutation and style recalculation, roughly a third of main-thread time, untouched by anything I did to the animation.

A plausible mechanism and a measured cost are different things. Only the trace tells you which is which.

Notes

Under prefers-reduced-motion: reduce the animation runs with duration: 0, snapping to the final state: the motion is a visual aid, not load-bearing. A rescale that arrives before the last one finishes cancels the in-flight animation and starts fresh, and the animation is cancelled on unmount, which keeps it correct under React’s double-mount in development.

The MDN Web Animations API reference covers the Element.animate handoff used here.