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