Under the Hood
Animation

Measuring animation: jank, dropped frames, and the tools

Every mechanism this module has described — the 16ms budget, the compositor thread, FLIP — is only useful once you can actually observe a frame being produced or dropped and attribute the drop to its real cause, which is what the DevTools Performance panel, the Rendering panel, and a handful of performance APIs are for.

Measuring animation: jank, dropped frames, and the tools

Every lesson in this module has made a mechanical claim you were asked to take on trust: that transform skips layout, that a long task drops exactly the frames it blocks, that FLIP costs two layout passes instead of sixty. None of that is useful if you can't go check it on a real page. "I think this animation is janky" is a feeling. "This animation drops 8 frames per second because a 40ms long task runs on every scroll event" is a diagnosis — and the difference between those two sentences is entirely about which tools you reached for and what you looked at. This lesson is about closing that gap: how to actually watch frames being produced and dropped, and how to point at the specific stage of the pipeline responsible when one is.

The Performance panel: recording what actually happened

Chrome DevTools' Performance panel is the primary instrument for this. You hit record, interact with the page while the animation you're worried about is running, stop recording, and get back a timeline of literally everything the browser did during that window, laid out against real wall-clock time.

Two parts of that timeline matter most for animation work. The Frames track, near the top, renders a small green (or red) bar for each frame the browser produced, sized proportionally to how long that frame took. A frame that overran the budget from lesson 1 — anything past 16.6ms on a 60Hz recording — is visually flagged, usually in red, and hovering it shows the exact duration. This is the direct, visual answer to "did I hit the deadline": a solid row of small, uniform, unflagged frames is smooth motion; a row with tall red spikes is exactly where the user saw a stutter, and the panel lets you click straight into that spike.

The main thread flame chart, below the frames track, is where you find out why a given frame was slow. It's a call stack rendered as nested horizontal bars over time — each bar is a function, its width is how long it (and everything it called) took, and stacking shows who called whom. A long task — any uninterrupted stretch of main-thread JavaScript recognized as blocking input and rendering, which the browser itself flags past roughly 50ms — shows up as a wide bar with a red diagonal hazard stripe in the corner. Click into it, and the flame chart underneath tells you exactly which function was running: your event handler, a layout-forcing read, a big JSON.parse, whatever it is. This is the tool that turns "the animation stutters sometimes" into "this stutters because renderList() runs a 45ms synchronous sort on every keystroke, and that 45ms is eating three consecutive frame deadlines" — the exact mechanism lesson 1 predicted for a long task, now visible as a specific labeled bar instead of an inference.

The Rendering panel: seeing the pipeline stages directly

DevTools' Rendering panel (opened via the command menu — "Show Rendering") gives you a handful of live overlays that answer questions the Performance panel's timeline can only imply.

The frame rendering stats overlay is a small always-on-top FPS meter — a live number and a GPU memory readout, updating continuously while you interact. It's the fastest way to confirm "is this actually janky right now," before you commit to recording a full performance trace.

Paint flashing highlights, in green, every screen region the browser actually repaints on a given frame. This is the direct, visual test of the claim from the layout/paint/composite lesson: if you've built an animation that's supposed to be transform-only and therefore composite-only, turning on paint flashing and running the animation should show no green flashing on the animating element at all — only composite is happening, so nothing is being re-rasterized. If the element flashes green every frame despite your transform-only CSS, something else is invalidating paint (a box-shadow recalculating, a filter, an SVG re-render underneath), and you've just found it without reading a single line of the flame chart.

Layer borders draws a border around every element the compositor has promoted to its own layer, which is the direct visual test of the will-change mechanism from lesson 4: does the element you expect to be independently composited actually have its own layer border, or is it still part of a larger painted surface that has to be re-rasterized as a whole whenever anything inside it changes? An animation that looks like it should be cheap can still be expensive if the element never actually got its own layer — layer borders tells you, at a glance, whether the promotion you assumed happened actually happened.

Measuring it yourself: rAF timestamps and PerformanceObserver

DevTools is where you diagnose during development; you also often want a number you can log, alert on, or ship telemetry for. Both are available programmatically.

The crudest and most direct: requestAnimationFrame hands you a high-resolution timestamp every time it fires (this is the same timestamp argument from lesson 1), so the gap between consecutive calls is your actual frame time, measured the same way the display experiences it:

let last = performance.now();

function measureFrame(now) {
  const frameTime = now - last; // should be ~16.6ms at 60Hz
  if (frameTime > 16.6 * 1.5) {
    console.warn(`Dropped frame: ${frameTime.toFixed(1)}ms`);
  }
  last = now;
  requestAnimationFrame(measureFrame);
}
requestAnimationFrame(measureFrame);

This tells you that a frame was late, but not why — for that, you want the browser's own attribution, which is exactly what the newer PerformanceObserver entry types are for. Long Animation Frames (LoAF) is purpose-built for this module's problem: it reports render-blocking frames specifically in animation-relevant terms, including how much of the frame's time went to script versus style/layout, and which script (down to the source location) was responsible.

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('Long animation frame:', entry.duration, 'ms');
    for (const script of entry.scripts) {
      console.log('  culprit:', script.sourceURL, script.startTime, script.duration);
    }
  }
}).observe({ type: 'long-animation-frame', buffered: true });

The older, more widely supported longtask entry type gives you a coarser version of the same signal — just the fact that a task exceeded 50ms and how long it ran — without the per-script breakdown. Either lets you collect this data from real users in production, not just from a local DevTools recording, which matters because jank on your machine and jank on a five-year-old Android phone are frequently very different animations.

The diagnostic decision path

Put the tools together and a janky animation resolves into one of three categories, and each looks distinctly different once you know where to look:

(a) Main-thread blocked by a long task. The Frames track shows dropped frames that line up exactly with a wide, hazard-striped bar on the main thread track — some JavaScript function, visible in the flame chart, running uninterrupted for tens of milliseconds. Paint flashing shows nothing unusual, because nothing is actually being painted during the freeze — the thread simply isn't getting to the rendering pipeline at all. This is lesson 1's failure mode exactly: wall-clock time on the main thread is dropped frames, and the flame chart tells you which function to fix (move it off the main thread, break it into smaller chunks, or cut the work entirely).

(b) Layout-bound. The Frames track shows frames that aren't necessarily catastrophically long individually, but a "Recalculate Style" and "Layout" pair of events appears in the timeline on every single frame of the animation, each costing a real, nonzero slice of the budget. This is the signature of animating a geometry property — top, left, width — directly, exactly as the layout/paint/composite lesson predicts: the pipeline is re-running layout because the property you're changing is itself an input to it. The fix is almost always to re-express the same visual motion as a transform, or, if the change is a layout change by nature (a reorder, a resize that pushes siblings), to reach for FLIP so layout runs twice total instead of once per frame.

(c) Paint-bound. Layout is cheap or absent, but paint flashing lights up a large region, every frame, and the flame chart shows real time in a "Paint" or "Composite Layers" event — often because the animating element has an expensive-to-rasterize effect (a large blur, a big drop-shadow, translucent overlapping content) that has to be re-rendered even though its geometry isn't changing. The fix here usually isn't "stop using transform" — you may already be using it — it's reducing what has to be repainted: shrinking the painted area, simplifying the effect, or, if the element is genuinely static in appearance and only needs to move, confirming via layer borders that it's actually been promoted to its own compositor layer so the expensive paint only has to happen once, not on every composite.

Three categories, three distinct signatures in the same set of tools — which is exactly why "measure before you optimize" isn't a platitude here. Guessing which of these three you have, and applying that category's fix to a problem that's actually a different category, will not help, and can make you confidently ship a change that does nothing.

Closing the module

Across this module you've built a single mental model of a browser frame, one piece at a time: the 16.6ms deadline itself and what has to fit inside it; how the browser interpolates between two values without your code running every frame; how easing reshapes that interpolation's timing; how the compositor thread lets certain properties keep hitting the deadline even when the main thread is busy; how the Web Animations API turns an animation into a queryable, redirectable, composable piece of state rather than a fire-and-forget instruction; how FLIP collapses an expensive layout change into two layout passes and one cheap transform; and now, how to actually watch all of that happen — or fail to happen — on a real page instead of taking any of it on faith. The mechanism was always available to look at directly. The tools in this lesson are how you go look.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why is 'measure first' not just good practice but the thing that prevents you from optimizing the wrong stage of the pipeline?
  2. What specifically does a red-flagged bar on the main thread flame chart tell you, and what does it mean when the Frames track shows dropped frames aligned with it?
  3. How would you use paint flashing to prove that a supposedly transform-only animation isn't secretly also triggering paint?
  4. What does the layer borders overlay let you confirm that will-change alone doesn't guarantee?
  5. Why does a rAF-timestamp-based frame timer tell you THAT a frame was dropped but not WHY, and what does PerformanceObserver's long-animation-frame entry add on top of that?
  6. Given the three-way decision path (main-thread-blocked, layout-bound, paint-bound), describe the distinct signature each one leaves in the Performance and Rendering panels.
  7. A teammate 'fixes' a janky list-reorder animation by adding will-change: transform, but the animation is still just as janky. Using this lesson's diagnostic path, what's the first thing you'd check before assuming the fix was wrong?