Frame Budgeting & 16ms Targets
Part of Performance Budgeting & GPU Architecture.
Frame budgeting is the discipline of partitioning each 16.67ms rendering cycle so that every browser phase — style recalculation, layout, paint, and compositing — completes before the next vertical-sync signal. When any phase overshoots its allotment, the browser misses the display’s refresh deadline and drops the frame, producing visible stutter. Controlling which work happens where — on the main thread versus the compositor thread — is the core skill that separates smooth, production-grade motion from janky prototypes.
What the 16.67ms Budget Actually Contains
At 60 frames per second the display hardware issues a vsync signal every 16.67ms. The browser must deliver a fully composited frame within that window or skip to the next one. The budget is not a single slot for JavaScript: it is a pipeline shared across five sequential stages.
| Browser phase | Typical budget share | Main thread? | Notes |
|---|---|---|---|
| JavaScript execution | 8–10ms | Yes | Includes requestAnimationFrame callbacks, event handlers, timers |
| Style recalculation | 1–2ms | Yes | Matching selectors and inheriting computed values |
| Layout | 1–2ms | Yes | Geometry computation; triggered by any box-model property change |
| Paint | 1–2ms | Yes | Rasterising pixels into layers |
| Composite | < 1ms | No (GPU) | Assembling layer textures on the compositor thread |
The key insight is that layout and paint can only start after JavaScript finishes. Consuming 14ms on script leaves 2ms for the remaining four phases — a near-guarantee of dropped frames. Cap main-thread tasks at 8–10ms and reserve the tail for the browser’s own housekeeping.
Execution Model: Main Thread vs Compositor Thread
The browser runs two concurrent threads that are relevant to animation: the main thread and the compositor thread. Understanding their division of labour is the fastest route to eliminating dropped frames.
The main thread owns everything that requires JavaScript access to the DOM: script execution, style cascade resolution, layout computation, and rasterising paint commands into bitmaps. It is single-threaded, so any long-running task blocks every downstream phase. A 50ms synchronous task means at least three dropped frames regardless of how well you’ve structured your CSS.
The compositor thread operates independently. It receives pre-rasterised layer textures from the main thread and composites them according to transform and opacity values. Crucially, it can animate those values between frames without consulting the main thread at all — this is what makes compositor-only property optimisation so effective. If the main thread is blocked by a long task, a compositor-thread animation keeps playing at full frame rate.
When invalidations fire: Any write to a layout-affecting property (width, height, margin, padding, top, left) forces the browser to re-run layout from the affected element outward. Reading a layout property immediately after writing one causes a forced synchronous layout — the browser must flush and recompute geometry synchronously before returning the measured value, interrupting the normal batched pipeline.
Property and API Reference
| Property / API | Accepted values | Compositing tier | Notes |
|---|---|---|---|
transform |
translate(), scale(), rotate(), matrix values |
Compositor | GPU-interpolated; no layout/paint cost |
opacity |
0–1 |
Compositor | Requires promoted layer; avoid on un-promoted elements |
will-change |
transform, opacity |
Compositor (promotes) | See layer promotion strategy for limits |
width / height |
Any length/percentage | Layout → Paint → Composite | Triggers full pipeline; avoid in animations |
margin / padding |
Any length/percentage | Layout → Paint → Composite | Same full-pipeline cost as width/height |
background-color |
Any colour value | Paint → Composite | Skips layout; still causes repaint |
box-shadow |
Shadow values | Paint → Composite | Expensive to repaint; animate with opacity on a pseudo-element instead |
requestAnimationFrame |
Callback function | Main thread | Syncs to vsync; pauses in hidden tabs |
PerformanceObserver (longtask) |
Entry type string | Main thread (passive) | Detects tasks > 50ms; use for production telemetry |
navigator.hardwareConcurrency |
Integer (logical CPUs) | N/A (query only) | Rough proxy for device capability; not a direct throttle signal |
Annotated Code Examples
Example 1: Runtime frame-budget monitor with adaptive fallback
// Attach a rAF loop that measures inter-frame delta and logs budget violations.
// Keep the callback itself lightweight — heavy work here defeats the purpose.
let lastFrameTime = 0;
const BUDGET_MS = 16.67;
const LONG_TASK_THRESHOLD_MS = 50; // PerformanceObserver longtask definition
let consecutiveDrops = 0;
function trackFrameBudget(timestamp) {
const delta = timestamp - lastFrameTime;
lastFrameTime = timestamp;
if (delta > BUDGET_MS) {
consecutiveDrops++;
// After three consecutive drops, trigger adaptive quality reduction.
if (consecutiveDrops >= 3) {
document.documentElement.classList.add('perf-reduced');
consecutiveDrops = 0;
}
} else {
consecutiveDrops = 0;
}
requestAnimationFrame(trackFrameBudget);
}
requestAnimationFrame(trackFrameBudget);
// Production telemetry: log long tasks (> 50ms) to your analytics pipeline.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task: ${entry.duration.toFixed(1)}ms`);
// Send entry.duration and entry.startTime to your telemetry endpoint.
}
});
observer.observe({ entryTypes: ['longtask'] });
Rendering Impact:
main-thread— this monitoring code runs on the main thread. TherAFcallback itself is negligible, but thePerformanceObserverhandler must remain synchronous and fast to avoid contributing to the very problem it is measuring.
Example 2: Compositor-safe card animation with batch DOM pattern
/*
Restrict to transform + opacity to keep interpolation on the compositor thread.
will-change pre-allocates a GPU layer before the hover interaction fires.
*/
.animated-card {
/* Promote the layer proactively — only use on frequently animated elements. */
will-change: transform, opacity;
transition:
transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.4s ease;
}
.animated-card:hover {
/* translateY + scale are compositor-safe: no layout recalculation fires. */
transform: translateY(-8px) scale(1.02);
opacity: 1;
}
/* Adaptive quality: apply when perf-reduced class is set by the JS monitor above. */
.perf-reduced .animated-card {
transition-duration: 0.15s;
will-change: auto; /* Release the GPU layer to conserve VRAM. */
}
/* Non-negotiable accessibility gate. */
@media (prefers-reduced-motion: reduce) {
.animated-card {
transition: none;
transform: none;
will-change: auto;
}
}
Rendering Impact:
composite—transformandopacitytransitions stay entirely on the compositor thread whenwill-changeis set. No layout or paint phases are triggered on hover.
Example 3: Batching DOM reads and writes to prevent forced synchronous layout
/*
Anti-pattern: interleaving reads and writes forces the browser to
flush layout between each pair, stalling the pipeline.
*/
// BAD — triggers forced synchronous layout on every iteration:
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
const h = card.offsetHeight; // read — forces layout flush
card.style.height = `${h + 10}px`; // write — invalidates layout
});
/*
Fixed pattern: collect all reads first, then apply all writes.
The browser batches the layout flush to the end of the current task.
*/
const heights = Array.from(cards).map(card => card.offsetHeight); // read phase
cards.forEach((card, i) => {
card.style.height = `${heights[i] + 10}px`; // write phase
});
/*
When measurements span multiple frames, defer writes to the next rAF
so they land at the start of the rendering pipeline, not mid-frame.
*/
function batchUpdate(cards) {
const heights = Array.from(cards).map(c => c.offsetHeight); // measure now
requestAnimationFrame(() => {
// Mutations happen at the top of the next frame — zero pipeline stall.
cards.forEach((card, i) => {
card.style.setProperty('--card-height', `${heights[i]}px`);
});
});
}
Rendering Impact:
main-threadfor the read/write operations; transitions subsequently promoted tocompositeif onlytransform/opacityare mutated. The batching pattern eliminates forced synchronous layout, keeping each frame’s layout phase to its normal 1–2ms budget share.
DevTools Workflow: Auditing Frame Budget Violations
Use Chrome DevTools Performance panel for the most detailed frame analysis.
-
Open DevTools and navigate to the Performance tab. Ensure CPU throttling is set to 4× or 6× slowdown to simulate mid-range mobile hardware — budget violations that are invisible on a desktop machine become obvious under throttling.
-
Enable extra rendering flags. In the Performance tab toolbar, click the gear icon and check “Enable advanced paint instrumentation”. In the Rendering panel (via the three-dot menu → More tools → Rendering), enable “Frame Rendering Stats” to overlay live FPS and GPU rasterisation status on the page.
-
Record a trace while interacting with the animated element. Aim for 3–5 seconds of animation.
-
Read the flame chart. Frames that exceed 16.67ms appear as red or yellow bars in the Frames row at the top. Click any red frame to zoom into the call stack. Look for:
- Long purple Scripting bars (JavaScript blocking the pipeline).
- Recalculate Style entries preceded by script — evidence of forced style invalidation.
- Layout calls inside a
requestAnimationFramecallback — the classic read/write interleave pattern. - Update Layer Tree entries that are unexpectedly large — may indicate over-promotion from excessive
will-change.
-
Identify the culprit call stack. Click any suspicious activity block to see its initiator. The Bottom-Up and Call Tree tabs reveal which function consumed the most time, including third-party scripts that may block during animation playback.
-
Firefox alternative. In Firefox DevTools, open the Performance tab and record a profile. The Waterfall view shows the same pipeline stages (JS → Style → Layout → Paint). Look for orange Forced Reflow markers — Firefox labels forced synchronous layout explicitly, which speeds up diagnosis.
-
Verify compositor promotion. In Chrome’s Layers panel (More tools → Layers), inspect the element. A composited layer will show “Compositing reasons” including
will-change. If the layer shows"Has overlapping composited descendant"or"Overlap", you may have an unintended layer explosion triggered by nearbywill-changedeclarations.
Failure Modes and Fixes
Problem: Animations stutter on scroll even though they use transform only.
Root cause: A scroll event listener is calling getBoundingClientRect() synchronously on every scroll event, forcing repeated layout flushes that bleed into the compositor’s available time.
Fix: Move scroll measurements into a requestAnimationFrame callback and throttle the listener: window.addEventListener('scroll', () => requestAnimationFrame(update), {passive: true}). Mark the listener passive to prevent blocking the compositor’s scroll thread.
Problem: The PerformanceObserver longtask callback fires constantly but the flame chart shows no obvious JS hotspot.
Root cause: Third-party scripts (analytics, ads, tag managers) are executing synchronously during the frame. They may be loaded with defer but still fire heavy initialisation tasks in the middle of animation sequences.
Fix: Load third-party scripts with type="module" or schedule them with scheduler.postTask({ priority: 'background' }) so they yield to rendering work. Audit third-party execution using the Third-party summary section in the Chrome DevTools Performance Insights panel.
Problem: Applying will-change: transform to many list items causes blank white flashes and GPU context loss on mobile.
Root cause: Each will-change element allocates a separate GPU texture. Promoting hundreds of elements simultaneously exhausts available VRAM, forcing the GPU to evict textures mid-animation.
Fix: Apply will-change only to elements that are currently animating or about to animate. Add it via JavaScript immediately before the interaction and remove it (set to auto) once the animation ends. See layer promotion and will-change strategy for a full decision framework.
Problem: A CSS @keyframes animation that only uses transform still shows Layout entries in the flame chart.
Root cause: The element or an ancestor has a property that creates a new block formatting context (overflow: hidden, contain: layout) but is also affected by the animation’s geometry changes due to percentage-based transform-origin values that require layout to resolve.
Fix: Ensure transform-origin uses fixed values (50% 50%, center) or pixel values, not values that depend on the element’s current laid-out size. Audit with the Layers panel to confirm the element is composited and not triggering repaints.
Problem: Frame drops only appear in the first 200ms of a page transition, then animation smooths out.
Root cause: CSS class additions that trigger the animation are applied during or immediately after the browser’s initial parse/paint cycle. The main thread is still busy finishing hydration, executing deferred scripts, and running DOMContentLoaded handlers.
Fix: Defer animation triggers by one rAF cycle: requestAnimationFrame(() => requestAnimationFrame(() => element.classList.add('animate-in'))). The double-rAF ensures at least one full frame has been committed to the screen before the animation begins.
Accessibility and Reduced-Motion Notes
Frame budget work and accessibility are complementary: the same compositor-safe patterns that reduce CPU load also make it easier to implement granular prefers-reduced-motion responses, because you are already controlling animations through well-scoped CSS custom properties and class toggles rather than scattered inline style mutations.
Apply reduced-motion as a cascade, not a hard off-switch, wherever content carries meaning through motion:
/*
Baseline: full animation for users who have not opted out.
Duration and easing are author-defined.
*/
.slide-in {
animation: slide-up 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
will-change: transform, opacity;
}
/*
Reduced-motion tier: keep the state change but remove the trajectory.
The element still appears; it just does so without spatial motion.
*/
@media (prefers-reduced-motion: reduce) {
.slide-in {
animation: fade-in 0.2s ease both;
will-change: auto;
}
}
@keyframes slide-up {
from { transform: translateY(24px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
For adaptive JavaScript-driven animations, pair prefers-reduced-motion with the long-task monitor: if either matchMedia('(prefers-reduced-motion: reduce)').matches is true or consecutive frame drops have been detected, switch to the simplified animation path. This treats accessibility and performance as equivalent signals rather than treating performance as an afterthought.
The Battery Status API (navigator.getBattery()) is Chrome-only and subject to removal for fingerprinting concerns; do not use it as the sole signal for animation degradation. prefers-reduced-motion and long-task frequency are more reliable and respect user intent explicitly.
Frequently Asked Questions
What happens when a single frame exceeds 16.67ms? The browser drops the frame, causing visible jank. The next frame renders at the next available vsync interval, effectively halving the perceived frame rate to 30fps until the main thread clears its backlog. On 120Hz displays the budget halves to 8.33ms, making budget discipline even more critical.
How does requestAnimationFrame differ from setInterval for animation?
rAF synchronises execution with the display’s refresh rate and automatically pauses in inactive tabs, so it never accumulates a backlog of pending callbacks. setInterval fires at fixed wall-clock intervals regardless of rendering readiness, frequently causing callbacks to stack up mid-frame and triggering layout thrashing that rAF would have naturally prevented.
Can CSS animations bypass the main thread entirely?
Yes — when restricted to transform and opacity, modern browsers promote the element to a dedicated compositor layer. The GPU handles interpolation and compositing between frames without invoking the main thread’s style or layout engines. This is why hardware-accelerated properties produce smooth animation even during heavy JavaScript execution.
How do I enforce frame budgets in production environments?
Implement a PerformanceObserver with the longtask entry type to log violations to your telemetry service. Set a threshold of three consecutive drops to trigger adaptive quality reduction — toggling a class that simplifies transitions or reduces their duration. This avoids over-reacting to isolated spikes caused by garbage collection.
Why is 8–10ms the recommended cap for JavaScript when the full budget is 16.67ms? The browser’s own style, layout, paint, and compositing phases consume the remaining 6–8ms. If JavaScript fills the entire 16.67ms window, the browser has no time left for its own rendering work and will consistently drop frames. The 8–10ms cap is a working margin that absorbs measurement jitter and ensures the pipeline’s downstream phases always have time to complete.
Related
- Performance Budgeting & GPU Architecture — parent overview of the full rendering pipeline budget model
- Compositor-Only Property Optimisation — the companion reference for which CSS properties stay off the main thread
- Layer Promotion & will-change Strategy — when and how to allocate GPU layers without exhausting VRAM
- Hardware-Accelerated Properties — the foundational property list that enables compositor-thread animation
- Auditing Layout Shifts During CSS Transitions — step-by-step DevTools workflow for finding layout-triggering transitions