Skip to content

The 16ms Frame Budget

advanced14 min read

The Number That Rules Every Frame

Your screen refreshes 60 times per second. That gives the browser exactly 16.67 milliseconds to produce each frame. Miss that deadline, and the user sees jank — a visible stutter that screams "this app is slow" louder than any loading spinner.

Here's the thing most engineers miss: you don't actually get the full 16.67ms. The browser has its own housekeeping — garbage collection, input processing, compositing overhead. After all that, you realistically get about 10ms of JavaScript execution time per frame. That's your budget. Blow it, and the frame drops.

Mental Model

Think of a conveyor belt running at a fixed speed — one frame leaves the factory every 16.67ms, no exceptions. Your JavaScript, the browser's style calculations, layout, paint, and compositing all share that single conveyor belt. If any step takes too long, the frame misses the belt and gets dropped. The user doesn't see a "slow frame" — they see a gap. Their scroll stutters, their animation freezes, and their confidence in your app takes a hit.

The Rendering Pipeline Per Frame

Every single frame the browser produces follows the same pipeline. Understanding where your time goes is the difference between guessing and knowing.

Not every frame runs every stage. That insight is the key to high-performance animation.

What Actually Fits in 16ms

Let's break down a realistic frame budget:

Total frame budget:           16.67ms
─────────────────────────────────────
Browser overhead:             ~2ms    (input handling, compositing, GC)
Style recalculation:          ~0.5ms  (small DOM, simple selectors)
Layout:                       ~1ms    (if triggered)
Paint:                        ~1ms    (if triggered)
Composite:                    ~0.5ms
─────────────────────────────────────
Remaining for YOUR JavaScript: ~11ms

That's the happy path. Now consider what happens when layout thrashing enters the picture — a single forced reflow in a loop can consume 50ms+ on a mid-range mobile device. Your 16ms budget doesn't just get blown. It gets obliterated.

Quiz
A 60Hz display refreshes every 16.67ms. After browser overhead, roughly how much time does your JavaScript get per frame?

The CSS Property Cost Table

Not all CSS changes are equal. Some properties trigger the full pipeline. Others skip expensive stages entirely. This table determines your animation strategy:

┌──────────────────────┬───────┬────────┬───────┬───────────┐
│ Property             │ Style │ Layout │ Paint │ Composite │
├──────────────────────┼───────┼────────┼───────┼───────────┤
│ width, height        │  ✓    │   ✓    │   ✓   │     ✓     │
│ margin, padding      │  ✓    │   ✓    │   ✓   │     ✓     │
│ top, left, right     │  ✓    │   ✓    │   ✓   │     ✓     │
│ font-size            │  ✓    │   ✓    │   ✓   │     ✓     │
│ border-width         │  ✓    │   ✓    │   ✓   │     ✓     │
├──────────────────────┼───────┼────────┼───────┼───────────┤
│ color                │  ✓    │        │   ✓   │     ✓     │
│ background-color     │  ✓    │        │   ✓   │     ✓     │
│ box-shadow           │  ✓    │        │   ✓   │     ✓     │
│ border-color         │  ✓    │        │   ✓   │     ✓     │
├──────────────────────┼───────┼────────┼───────┼───────────┤
│ transform            │  ✓    │        │       │     ✓     │
│ opacity              │  ✓    │        │       │     ✓     │
│ filter (on layer)    │  ✓    │        │       │     ✓     │
└──────────────────────┴───────┴────────┴───────┴───────────┘

The bottom rows are gold. transform and opacity skip layout AND paint — they run entirely on the compositor thread, off the main thread. Your JavaScript can be busy doing other work and the animation still runs smoothly at 60fps.

Compositor-Only Animations

This is the single highest-impact optimization for animation performance. When you animate transform or opacity on an element that has its own compositing layer, the animation runs on the compositor thread — completely independent of the main thread.

/* SLOW: Animating top triggers Layout + Paint + Composite every frame */
.card-bad {
  position: absolute;
  transition: top 300ms ease;
}
.card-bad:hover {
  top: -10px;
}

/* FAST: Animating transform runs only on compositor thread */
.card-good {
  transition: transform 300ms ease;
}
.card-good:hover {
  transform: translateY(-10px);
}

Both produce the same visual result — the card moves up 10px on hover. But the performance characteristics are night and day:

  • Animating top: triggers layout (recalculate every element's position), paint (redraw affected area), composite. All on the main thread. If JavaScript is busy, the animation stutters.
  • Animating transform: the element already has a GPU texture from its compositing layer. The compositor just moves the texture. Zero main thread involvement. Silky smooth even during heavy JS execution.
Why only transform and opacity?

The compositor thread can only manipulate existing GPU textures — it can move them (translate), rotate them, scale them, and fade them (opacity). It cannot change what's drawn inside a texture. Changing color or background-color requires actually drawing new pixels, which means paint must run on the main thread. Changing width is even worse — it changes the geometry of the element and everything around it, requiring layout.

This is a fundamental architectural constraint, not a browser limitation. The compositor is intentionally simple and fast because it must never block. It's the last line of defense against jank.

Quiz
You need to animate a modal sliding in from the right edge. Which approach gives the smoothest result?

Layout Thrashing and Forced Reflow

Layout thrashing is the most common cause of frame budget violations. It happens when you interleave reads and writes to the DOM, forcing the browser to synchronously recalculate layout multiple times in a single frame.

// THRASHING: Forces synchronous layout on every iteration
function resizeCards(cards) {
  for (const card of cards) {
    const parentWidth = card.parentElement.offsetWidth; // READ → forces layout
    card.style.width = (parentWidth * 0.9) + 'px';     // WRITE → invalidates layout
  }
}

Each offsetWidth read forces the browser to flush pending style changes and compute layout synchronously. With 100 cards, that's 100 forced layouts in a single frame. On a mid-range phone, that's easily 200ms — twelve dropped frames.

The Fix: Separate Reads from Writes

// BATCHED: One layout calculation total
function resizeCards(cards) {
  const widths = cards.map(card => card.parentElement.offsetWidth);

  for (let i = 0; i < cards.length; i++) {
    cards[i].style.width = (widths[i] * 0.9) + 'px';
  }
}

Read all values first (one layout), then write all values (one layout at end of frame). Two layouts instead of 100.

Common Trap

Framework-level abstractions do not protect you from layout thrashing. React batches state updates, but if your useEffect reads layout properties and a useLayoutEffect writes styles, you can still trigger forced reflows. Virtual DOM diffing does not eliminate DOM reads — the browser still must compute layout when you ask for getBoundingClientRect() or offsetHeight. Always profile your actual DOM operations, not just your React component renders.

requestAnimationFrame Timing

requestAnimationFrame (rAF) fires right before the browser runs style/layout/paint for the next frame. This makes it the correct place for any visual DOM mutation.

// RIGHT: Visual updates go in rAF
function animateProgress(element, targetWidth) {
  let currentWidth = 0;

  function step() {
    currentWidth += 2;
    element.style.width = currentWidth + 'px';

    if (currentWidth < targetWidth) {
      requestAnimationFrame(step);
    }
  }

  requestAnimationFrame(step);
}
// WRONG: setTimeout has no relationship to the display refresh
function animateProgress(element, targetWidth) {
  let currentWidth = 0;

  function step() {
    currentWidth += 2;
    element.style.width = currentWidth + 'px';

    if (currentWidth < targetWidth) {
      setTimeout(step, 16); // NOT synced to display — causes tearing
    }
  }

  setTimeout(step, 16);
}

The difference: setTimeout(fn, 16) fires approximately every 16ms, but it drifts relative to the display refresh. Some frames get two updates (wasted work), others get zero (visual stutter). requestAnimationFrame is synchronized to the display's actual vsync signal.

Quiz
When exactly does a requestAnimationFrame callback execute relative to the rendering pipeline?

The Double-rAF Trick

Sometimes you need to read layout values that depend on changes from the current frame. A single rAF won't work because the browser hasn't processed your changes yet. The double-rAF technique defers the read to the next frame:

element.classList.add('expanded');

requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    const newHeight = element.offsetHeight;
    console.log('Height after expansion:', newHeight);
  });
});

The first rAF fires before rendering — the browser processes the class change during this frame's style/layout. The second rAF fires before the next frame, by which point layout is complete and offsetHeight returns the correct value without forcing a synchronous reflow.

will-change: The Double-Edged Hint

will-change tells the browser to prepare for a specific property animation by promoting the element to its own compositing layer ahead of time.

/* Promotes to its own layer BEFORE animation starts */
.menu {
  will-change: transform;
}

/* Layer created on hover — may cause visual glitch on first trigger */
.menu-lazy {
  /* no will-change */
}
.menu-lazy:hover {
  will-change: transform;
  transform: translateX(250px);
}

Why It Costs Memory

Every compositing layer consumes GPU memory. A 500x400px element on a 2x device pixel ratio display uses:

1000px × 800px × 4 bytes (RGBA) = 3.2MB per layer

Slap will-change: transform on 50 elements and you're burning 160MB of GPU memory. On a mobile device with 256MB total GPU memory, that's a guaranteed performance regression — the GPU starts evicting and re-uploading textures, which is slower than not having layers at all.

Key Rules
  1. 1Only apply will-change to elements that will actually animate soon
  2. 2Remove will-change after the animation completes when applied via JavaScript
  3. 3Never put will-change in a global stylesheet on dozens of elements
  4. 4Profile GPU memory in DevTools (Layers panel) before and after adding will-change
  5. 5On mobile, every extra layer is expensive — be even more conservative
const menu = document.querySelector('.sidebar-menu');

menu.addEventListener('mouseenter', () => {
  menu.style.willChange = 'transform';
});

menu.addEventListener('transitionend', () => {
  menu.style.willChange = 'auto';
});

CSS contain for Rendering Isolation

contain tells the browser that an element's internals are independent from the rest of the page. This lets the browser skip recalculating layout or paint for elements outside the contained subtree.

/* Each card is isolated — changes inside don't trigger layout/paint outside */
.card {
  contain: layout paint;
}

/* Full containment — maximum optimization but requires explicit dimensions */
.widget {
  contain: strict;
  width: 300px;
  height: 200px;
}

/* content-visibility skips rendering off-screen elements entirely */
.feed-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 300px;
}

Containment Types

  • contain: layout — layout changes inside don't propagate outward. The element becomes a layout boundary.
  • contain: paint — nothing inside paints outside the element's bounds. Off-screen contained elements skip paint entirely.
  • contain: size — the element's size doesn't depend on its children. You must set explicit dimensions.
  • contain: style — counters and quotes scoped to this subtree (rarely needed alone).
  • contain: strict — shorthand for layout paint size style. Maximum isolation, maximum savings.
  • contain: content — shorthand for layout paint style. Like strict but without size containment.
content-visibility: auto is free performance

Adding content-visibility: auto to long lists or below-the-fold sections can eliminate hundreds of milliseconds from initial render. The browser skips layout, paint, and compositing for off-screen elements entirely, only rendering them as they scroll into view. Pair it with contain-intrinsic-size to prevent layout shifts when elements pop in.

Quiz
You have a dashboard with 200 cards in a grid. Scrolling is janky on mobile. Which CSS property should you try first?

Measuring with the Performance API

Guessing doesn't work. You need to measure actual frame timing to know where your budget goes.

Frame Timing with rAF

let lastFrameTime = performance.now();
let frameCount = 0;
let droppedFrames = 0;

function measureFrames() {
  const now = performance.now();
  const frameDuration = now - lastFrameTime;
  lastFrameTime = now;
  frameCount++;

  if (frameDuration > 18) {
    droppedFrames++;
    console.warn(`Dropped frame: ${frameDuration.toFixed(1)}ms`);
  }

  requestAnimationFrame(measureFrames);
}

requestAnimationFrame(measureFrames);

Long Animation Frames API

The newer Long Animation Frames (LoAF) API provides detailed breakdowns of frames that exceed 50ms:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('Long frame:', {
      duration: entry.duration,
      blockingDuration: entry.blockingDuration,
      renderStart: entry.renderStart,
      styleAndLayoutStart: entry.styleAndLayoutStart,
      scripts: entry.scripts.map(s => ({
        name: s.name,
        duration: s.duration,
        sourceURL: s.sourceURL
      }))
    });
  }
});

observer.observe({ type: 'long-animation-frame', buffered: true });

Chrome DevTools Performance Panel

The most powerful tool for frame analysis:

  1. Open DevToolsPerformance tab
  2. Click Record, interact with the page, then Stop
  3. Look at the Frames row — green bars are good frames, yellow/red are slow
  4. Click a slow frame to see exactly which stage took too long
  5. The Main flame chart shows your JavaScript execution
  6. Recalculate Style and Layout blocks in purple show rendering work
Reading the Performance flame chart

In the flame chart, look for these specific patterns:

Long yellow blocks in the Main thread: your JavaScript is taking too long. Profile the function, optimize or break it up with requestIdleCallback or scheduler.yield().

Repeating purple Layout blocks: layout thrashing. You're interleaving reads and writes. Batch them.

Wide green Paint blocks: you're repainting large areas. Check if you can use will-change to promote the animated element to its own layer, or contain: paint to limit the repaint area.

Gaps between frames: the main thread was blocked by something else — maybe garbage collection (look for minor GC events) or a long task that wasn't yielded.

Putting It All Together

Here's a real-world example: building a smooth card hover animation that respects the frame budget.

.card {
  contain: layout paint;
  transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1),
              box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1);
  will-change: transform;
}

.card:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 24px var(--color-shadow);
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
    will-change: auto;
  }
}

This hits every optimization:

  • contain: layout paint — card changes don't affect siblings or parent
  • transform instead of top — compositor-only animation
  • will-change: transform — layer promoted ahead of time (justified because cards are the primary interactive elements)
  • cubic-bezier — smooth easing that feels physical
  • prefers-reduced-motion — respects accessibility preferences and removes the layer promotion to save GPU memory
What developers doWhat they should do
Animating left/top/margin for element movement
left/top/margin trigger layout recalculation on every frame, blocking the main thread. transform runs on the compositor thread without touching layout or paint.
Use transform: translate() for all position animations
Adding will-change: transform to all elements in a global stylesheet
Each will-change layer consumes GPU memory (3-8MB per element on retina). Excess layers cause GPU memory pressure and texture thrashing, which is slower than no layers.
Only apply will-change to elements that will animate, and remove it after animation
Using setTimeout(fn, 16) for animations
setTimeout is not synchronized to the display refresh. It drifts, causing some frames to get double updates and others to get none. rAF is synced to vsync and fires at exactly the right time.
Use requestAnimationFrame for all visual updates
Reading offsetHeight right after changing styles in the same synchronous block
Reading layout properties after a write forces synchronous reflow — the browser must compute layout immediately instead of batching it. In a loop, this causes layout thrashing.
Batch all reads before writes, or defer reads to the next frame with rAF
Assuming React or framework abstractions prevent layout thrashing
Frameworks batch state updates, not DOM reads. useLayoutEffect, refs accessing offsetHeight, and getBoundingClientRect all force synchronous layout regardless of framework.
Profile actual DOM operations with DevTools Performance panel
Quiz
A page has a scroll-linked animation that reads scrollTop and updates an element's transform on every scroll event. The animation is janky. What is the most effective fix?
Key Rules
  1. 1Animate only transform and opacity for 60fps — everything else triggers layout or paint
  2. 2Your real JavaScript budget per frame is ~10ms, not 16.67ms
  3. 3Batch DOM reads before writes — never interleave them
  4. 4Use requestAnimationFrame for all visual mutations, never setTimeout
  5. 5Apply will-change sparingly — each layer costs 3-8MB GPU memory on retina displays
  6. 6Use contain: layout paint to isolate components from affecting siblings
  7. 7Add content-visibility: auto to off-screen content for free rendering savings
  8. 8Always measure with DevTools Performance panel — never assume where time goes
  9. 9Respect prefers-reduced-motion by disabling animations for users who need it