Under the Hood
Animation

The frame: what 60fps actually asks of the browser

On screen, smoothness is not a feeling — it is a deadline the browser has to hit sixty times a second. This lesson takes apart a single frame — where your JavaScript runs inside it, how much of the 16.6ms is actually yours, why requestAnimationFrame is the only correct place to drive an animation, and what physically happens on screen when you miss the deadline.

The frame: what 60fps actually asks of the browser

People describe animation quality with words like "smooth," "buttery," or "janky," as if it were a matter of taste. It isn't. Underneath every one of those words is a hard, countable deadline, and whether your animation is smooth or janky comes down to a single yes-or-no question the browser asks itself sixty times a second: did the finished frame arrive before the screen needed it? Once you can see that deadline — where it comes from, how much of it is yours to spend, and what happens the instant you overshoot it — "make it smoother" stops being a vibe and becomes an engineering problem with a number attached.

This lesson is about that one frame. Not the animation — the frame. Everything else in this module (interpolation, easing, the compositor, FLIP) is a strategy for getting a correct-looking frame produced before its deadline. So before any of that, you need to know exactly what a frame is, what has to happen inside one, and what the clock is.

Where the deadline comes from

Your screen is not continuously lit. It redraws itself at a fixed cadence — for most displays, 60 times per second — and that cadence is set by the hardware, not by your code. Each redraw is called a refresh, and the signal that a refresh is about to happen is called vsync (vertical synchronization). The display is going to sample whatever image is ready and show it, on its own schedule, whether or not the browser is finished.

Sixty refreshes per second means one refresh every 1000 ÷ 60 ≈ 16.6 milliseconds. That number is the entire game. To show smooth motion, the browser has to hand the display a new, updated image once per refresh — a fresh frame every 16.6ms. Produce it in time and the motion advances by one step, cleanly. Fail to produce it in time and the display has nothing new to show, so it shows the previous frame again — the picture is frozen for those 16.6ms — and the eye reads that stall as a stutter.

So "60fps" isn't a quality setting. It's the statement "I produced a frame for every one of the display's 60 refreshes this second." "Janky" is the statement "I missed some." There is no in-between and no partial credit: a frame is either ready at vsync or it is not.

What has to happen inside one frame

Producing a frame is not a single action. Every frame, the browser runs the same ordered sequence of work on its main thread — the single thread that also runs all your JavaScript. Roughly, in order:

  1. Input handling — process any pending events (a click, a scroll, a pointer move) and run their handlers.
  2. requestAnimationFrame callbacks — run any callbacks you registered for "just before the next paint" (more on this in a moment; this is where a well-written animation does its per-frame work).
  3. Style — recompute which CSS rules apply to which elements and what their final computed values are.
  4. Layout — work out the geometry: the size and position of every affected box.
  5. Paint — rasterize the pixels for each layer.
  6. Composite — assemble the painted layers into the final image and hand it to the display.

The three stages at the end — style, layout, paint, composite — are the subject of a separate lesson (Layout, paint, composite), because which of them your change triggers is what decides how expensive it is. For this lesson the important thing is coarser: all of this work, for one frame, has to finish inside 16.6ms, and most of it runs on the one main thread that is also running your JavaScript. Steps 2 through 5 are competing for the same thread. If your rAF callback in step 2 spends 12ms doing math, the browser has about 4ms left for style, layout, and paint before the deadline — and if those need more than 4ms, the frame is late.

The budget is not 16.6ms of "your code"

This is the misconception that produces most self-inflicted jank. It's tempting to read "16.6ms per frame" as "I get 16.6ms to run my code." You don't. The 16.6ms is the budget for everything the browser must do to produce the frame — your JavaScript plus style, layout, paint, and composite. The browser needs a meaningful slice of every frame for its own rendering work, and that slice isn't free.

A useful working figure: assume you have somewhere around 10ms of headroom for your own JavaScript in a frame where the browser also has real style/layout/paint work to do, and treat anything beyond that as borrowing against the browser's share. That's not a hard constant — a frame that only composites (nothing changed geometrically) leaves you much more room; a frame that relayouts a large subtree leaves you much less — but it kills the "I have the whole 16ms" instinct, which is the one that gets you in trouble.

The consequence: a single long-running piece of JavaScript doesn't cost you one frame. Because the main thread can't be interrupted to go render, a task that runs for, say, 50ms holds the thread for the duration of three consecutive frame deadlines. Those three frames simply never get produced — the last painted image sits frozen on the display for 50ms — and the user sees a visible hitch. This is why "it's just one function" is not a defense: on the main thread, wall-clock time is dropped frames.

// This looks harmless. On the main thread it is three dropped frames.
button.addEventListener('click', () => {
  const result = expensiveSynchronousWork(); // blocks the thread for ~50ms
  render(result);
});
// For those 50ms the browser cannot produce a single frame. Any animation
// currently running on the main thread freezes solid until this returns.

requestAnimationFrame: the one correct place to drive a frame

If you're moving something by changing a value over time in JavaScript — a counter, a scroll-driven parallax, a hand-rolled tween — when you run that update matters as much as what it does. There is exactly one hook designed for it: requestAnimationFrame (rAF). You hand it a callback; the browser promises to run that callback once, just before it produces the next frame — precisely at step 2 of the sequence above.

That timing is the whole point, and it's worth being precise about why the obvious alternatives are wrong:

  • setInterval(fn, 16) / setTimeout are timer-based, not frame-based. They fire on a millisecond clock that has no relationship to vsync. Sometimes your callback runs early in a frame, sometimes late, sometimes twice between two refreshes, sometimes not at all before a refresh — because 16ms is not exactly 16.6ms, the timer slowly drifts against the display's cadence. The visual result is periodic stutter even when you're doing barely any work, because your position updates and the screen's refreshes are beating against each other instead of marching in step.
  • Timers also keep firing when the tab is hidden, burning CPU (and battery) to animate a frame nobody is looking at. requestAnimationFrame doesn't: the browser stops calling your rAF callbacks entirely while the tab is in the background, because there are no frames being produced to run them before. You get pause-when-hidden for free.
// Wrong: a timer that drifts against vsync and runs in hidden tabs.
setInterval(() => {
  x += 2;
  el.style.transform = `translateX(${x}px)`;
}, 16);

// Right: synced to the frame, paused when the tab is hidden.
function step(now) {          // `now` is a high-res timestamp, in ms
  x += 2;
  el.style.transform = `translateX(${x}px)`;
  requestAnimationFrame(step); // re-register for the next frame
}
requestAnimationFrame(step);

Two details in that correct version repay attention. First, rAF is one-shot: it schedules your callback for the next frame only, so a continuous animation has to re-register itself each time (the trailing requestAnimationFrame(step)). Second, the callback receives a timestamp argument — a high-resolution "current time" for this frame. Using that timestamp to compute position (rather than assuming a fixed step per call) is what makes an animation time-based instead of frame-based: if a frame is dropped and the next callback runs 33ms later instead of 16ms later, a time-based animation moves twice as far to compensate and stays on schedule, while a fixed-step animation silently runs in slow motion. We'll build on that distinction in the interpolation lesson; for now the takeaway is that rAF hands you the clock precisely so you can stay honest about elapsed time.

What a dropped frame actually looks like

Put the pieces together and you can predict jank instead of just noticing it. The display refreshes on its fixed 16.6ms cadence no matter what. If, at the moment of a given vsync, the browser has not finished compositing a new frame — because a long task held the main thread, or because style/layout/paint for this frame genuinely needed more than the time left after your JavaScript ran — then the display has nothing new to sample. It re-shows the previous frame. The animation's position does not advance for that refresh. One refresh later, the browser finally finishes; now the position jumps forward by two steps' worth of motion in a single refresh, because time kept moving while the frame was stuck.

That is the entire physical basis of "jank": a missed deadline produces a freeze (the repeated frame) followed by a jump (the catch-up). A steady stream of missed deadlines is a steady stream of freeze-jump, freeze-jump — which the eye reads as roughness. Nothing is "lagging" in the sense of slowing down; frames are being skipped and the survivors are spaced unevenly. This is also why an animation can measure as "fast" (it completes in the right total duration) and still look terrible: total duration and per-frame evenness are different properties, and smoothness is entirely the second one.

Why this frames the whole module

Everything that follows is, at bottom, a way to get a good-looking frame produced before its deadline more reliably:

  • Interpolation and the Web Animations API exist so the browser can advance an animation's values for you, on its own schedule, without your JavaScript having to run every frame at all.
  • The compositor thread is the browser's escape hatch from this entire problem: certain animations can be run on a separate thread that isn't blocked by your main-thread JavaScript, so they keep hitting the deadline even when the main thread is busy.
  • FLIP is a trick for animating layout changes using only the cheap, deadline-friendly parts of the frame, so an animation that looks like it should cost a layout every frame doesn't.

Each of those is a specific answer to the question this lesson set up: how do I keep producing frames before vsync? You can't evaluate any of them without the deadline in view, which is why we started here.

Where this goes next

The next lesson, Transitions vs keyframe animations, picks up the most immediate consequence of everything above: if hitting the deadline every frame is this demanding, you very often don't want to be the one driving each frame from JavaScript at all. You'd rather describe the motion once and let the browser advance it for you — which is exactly what CSS transitions and animations are. To understand what they buy you, though, you first have to understand what "advance the motion" means mechanically: how the browser interpolates between two values, frame by frame, on your behalf.

Go deeper

  • MDN — requestAnimationFrame The precise contract for the one-shot callback and its timestamp argument, including the hidden-tab pausing behavior this lesson relies on.
  • web.dev — Rendering performance The per-frame pipeline and the 16ms budget from the browser team's own perspective, with the frame anatomy this lesson walks through.
  • Chrome DevTools — Performance panel Where you actually watch frames being produced and dropped against the vsync timeline — the diagnostic tool the final lesson in this module uses.

Check yourself

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

  1. Where does the 16.6ms number come from, and what is the correct budget on a 120Hz display?
  2. A colleague says 'we have 16 milliseconds per frame for our JavaScript.' What's wrong with that framing, and roughly how much is realistically yours in a frame that also does layout and paint?
  3. Explain, in terms of frame deadlines, why a single synchronous 50ms task drops three frames rather than one.
  4. Give two distinct reasons requestAnimationFrame is the correct hook for a JS-driven animation and setInterval(fn, 16) is not.
  5. requestAnimationFrame is 'necessary but not sufficient' for smooth animation. What does it guarantee, and what does it explicitly not fix?
  6. Describe the freeze-then-jump pattern of a dropped frame. Why can an animation finish in the correct total time and still look janky?
  7. Why does the callback's timestamp argument let a time-based animation survive a dropped frame gracefully, while a fixed-step-per-callback animation runs in slow motion instead?