Skip to content

The 16ms Frame Budget

advanced15 min read

Why 16.67ms Matters

You have 16.67 milliseconds. That is your entire budget for a smooth frame. Miss it, and your user sees jank — that ugly stutter in scrolling, animation, or interaction that screams "this app is slow." At 60fps, each frame gets exactly 1000ms / 60 = 16.67ms, and human perception is ruthless about detecting when you blow it.

Mental Model

Think of the browser as a factory with a conveyor belt running at fixed speed. Every 16.67ms, a new frame slot passes by. If the frame's work is not finished, the slot goes empty — the user sees the previous frame repeated. Two consecutive dropped frames and the user notices. Five consecutive drops and the UI feels broken. Your JavaScript, style calculations, layout, paint, and compositing all compete for time on the same conveyor belt.

Anatomy of a Single Frame

So what actually happens inside those 16.67ms? Within each frame, the browser must execute these stages in order:

┌─────────────────── 16.67ms Frame ───────────────────┐
│                                                       │
│  Input events (touch, click, scroll handlers)         │
│  ↓                                                    │
│  JavaScript (rAF callbacks, event handlers, timers)   │
│  ↓                                                    │
│  Style recalculation (match selectors, compute styles)│
│  ↓                                                    │
│  Layout (calculate geometry for all affected elements)│
│  ↓                                                    │
│  Paint (generate display lists per layer)             │
│  ↓                                                    │
│  Composite (combine layers, send to GPU)              │
│                                                       │
└───────────────────────────────────────────────────────┘

The browser itself needs ~4-6ms for style/layout/paint/composite. That leaves you roughly 10-12ms for JavaScript in a frame that must stay smooth.

Quiz
A frame at 60fps has 16.67ms total. The browser needs ~6ms for rendering work. How much time is left for JavaScript?

Long Tasks: The Frame Budget Destroyer

This is where things go sideways in production. The Web Performance working group defines a long task as any task that takes more than 50ms on the main thread. That is roughly 3 frames blown. Long tasks are the primary cause of poor Interaction to Next Paint (INP) scores and visual jank.

// This is a long task — 200ms of uninterrupted main thread work
function processData(items) {
  const results = [];
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i])); // Blocks for 200ms total
  }
  return results;
}

During a 200ms long task:

  • 12 frames are dropped (200ms / 16.67ms)
  • No input events are processed (clicks, scrolls feel unresponsive)
  • No animations advance (CSS transitions freeze)
  • No requestAnimationFrame callbacks fire
Execution Trace
Frame 1
JS starts executing processData()
Budget: 10ms for JS. Task needs 200ms.
Frame 2
JS still running
Frame deadline missed. Frame dropped.
Frame 3-12
JS still running
10 more frames dropped. User sees frozen UI.
Frame 13
JS finishes. Browser runs style/layout/paint.
First visual update in 200ms. User experienced jank.

Input Latency and INP

Here is why long tasks really hurt. When a user taps a button, the browser queues the event. If a long task is running, the event just... waits. Input latency is the time from the user's action to the browser's visual response, and users feel it.

User taps button
    ↓
[====== 150ms long task running ======]
                                        → Event handler runs (20ms)
                                        → Style/Layout/Paint (5ms)
                                        → Pixel update visible
Total: 175ms input latency (poor INP)

Chrome's INP metric measures this end-to-end. A good INP is under 200ms. A 150ms long task plus 20ms event handler already pushes you to 170ms — dangerously close to the threshold.

Quiz
A user clicks a button while a 100ms task is running. The click handler itself takes 30ms. What is the total input latency?

Yielding to the Browser

So how do you fix long tasks? You yield. You break work into chunks and give the browser opportunities to process frames and input events between chunks. There are several ways to do this, and they are not all created equal.

scheduler.yield() (Modern)

The most ergonomic approach, supported in Chrome 115+:

async function processData(items) {
  const results = [];
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));

    // Yield every 5ms of work
    if (i % 100 === 0) {
      await scheduler.yield();
    }
  }
  return results;
}

scheduler.yield() pauses execution, lets the browser process pending events and render a frame, then resumes. Unlike setTimeout(0), yielded tasks maintain their priority in the task queue — they are not sent to the back of the line.

setTimeout(0) (Universal)

function processChunk(items, index, results, callback) {
  const end = Math.min(index + 100, items.length);
  for (let i = index; i < end; i++) {
    results.push(expensiveTransform(items[i]));
  }
  if (end < items.length) {
    setTimeout(() => processChunk(items, end, results, callback), 0);
  } else {
    callback(results);
  }
}
Common Trap

setTimeout(fn, 0) does not actually execute after 0ms. Browsers clamp the minimum delay to ~4ms (HTML spec requires 4ms for nested timeouts beyond depth 5). In practice, the yielded task runs in the next macrotask slot, after any pending input events and rendering work. This means the browser gets a chance to paint, but there is a minimum ~4ms overhead per yield. For very fine-grained yielding, this overhead adds up.

MessageChannel (Faster than setTimeout)

const channel = new MessageChannel();
const port = channel.port2;

function yieldToMain() {
  return new Promise(resolve => {
    channel.port1.onmessage = resolve;
    port.postMessage(null);
  });
}

async function processData(items) {
  const results = [];
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
    if (i % 100 === 0) {
      await yieldToMain();
    }
  }
  return results;
}

MessageChannel avoids the 4ms clamp of setTimeout. Messages are delivered as macrotasks without the minimum delay, giving the browser a chance to render while minimizing yielding overhead.

Quiz
Why is MessageChannel preferred over setTimeout(0) for yielding?

Measuring Frame Drops

Performance Panel (DevTools)

The Performance panel's frame timeline shows:

  • Green bars: Frames rendered on time
  • Red bars: Frames that missed the 16.67ms deadline
  • Gaps: Frames that were dropped entirely

Frame Timing API

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Long frames indicate jank
    if (entry.duration > 16.67) {
      console.warn(`Frame took ${entry.duration.toFixed(1)}ms`);
    }
  }
});
observer.observe({ type: 'long-animation-frame', buffered: true });

Manual Frame Counting

let lastTime = performance.now();
let droppedFrames = 0;

function checkFrames() {
  const now = performance.now();
  const delta = now - lastTime;

  if (delta > 20) { // Allow small tolerance over 16.67ms
    droppedFrames += Math.floor(delta / 16.67) - 1;
  }

  lastTime = now;
  requestAnimationFrame(checkFrames);
}
requestAnimationFrame(checkFrames);

120Hz Displays: The 8.33ms Budget

And just when you thought 16.67ms was tight — modern phones (iPhone Pro, Samsung Galaxy S series, iPad Pro) and monitors run at 120Hz. The frame budget halves to 8.33ms. Work that was perfectly safe at 60fps? It janks at 120Hz.

60Hz:  16.67ms per frame → ~10ms JS budget
120Hz:  8.33ms per frame → ~4ms JS budget
Test on high refresh rate devices

Performance that looks smooth on a 60Hz monitor can jank on a 120Hz phone. If your users have flagship devices, profile on 120Hz. Chrome DevTools lets you throttle to different frame rates for testing.

Quiz
A task takes 8ms. On which displays will this cause dropped frames?

Practical Strategy: Time-Sliced Rendering

For rendering large lists or processing heavy data:

async function renderItems(items, container) {
  const CHUNK_SIZE = 20;
  const BUDGET_MS = 8; // Conservative for 120Hz

  let index = 0;

  while (index < items.length) {
    const start = performance.now();

    // Render items until budget is exhausted
    while (index < items.length && (performance.now() - start) < BUDGET_MS) {
      const el = createItemElement(items[index]);
      container.appendChild(el);
      index++;
    }

    // Yield to browser for rendering and input processing
    if (index < items.length) {
      await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
    }
  }
}

This approach renders as many items as possible within the budget, yields for a frame, then continues. The browser gets consistent opportunities to paint and process events.

Key Rules
  1. 160fps = 16.67ms per frame. The browser needs ~6ms, leaving ~10ms for JavaScript.
  2. 2120Hz displays halve the budget to 8.33ms — test on high refresh rate devices.
  3. 3A long task (>50ms) drops multiple frames and blocks input processing, destroying INP.
  4. 4Yield using scheduler.yield() (modern), MessageChannel (fast, broad support), or setTimeout(0) (universal, 4ms overhead).
  5. 5React's scheduler uses MessageChannel internally for exactly this reason — to yield without the setTimeout clamp.
  6. 6Measure frame drops with the Performance panel, Long Animation Frame API, or manual rAF-based counting.
  7. 7Time-slice heavy work: render/process in chunks, check elapsed time, yield when budget is consumed.
Interview Question

Q: Users report that your app stutters when scrolling through a large list. The list has 500 items, each with a complex layout. Walk me through how you would diagnose and fix this.

A strong answer covers: open Performance panel, record a scroll interaction, look for long tasks and dropped frames (red/missing frame bars). Identify whether the bottleneck is JS (event handlers, intersection observers), layout (too many elements reflowing), or paint (complex layers, shadows, filters). For JS: yield to browser between chunks of work. For layout: virtualize the list (only render visible items), use CSS containment. For paint: promote animated elements to compositor layers, simplify paint-heavy CSS. Mention that 500 full DOM nodes may be the root cause — windowing/virtualization should be the first consideration.