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.


16.67ms Frame Budget Timeline A horizontal timeline divided into five coloured segments representing JavaScript, Style, Layout, Paint, and Composite phases within a single 16.67ms frame. 0ms 16.67ms JavaScript 8–10ms (target cap) Style ~1ms Layout ~1ms Paint ~1ms Comp- osite GPU Overshooting JS into Style/Layout/Paint time = dropped frame JS deadline: ~10ms safe zone

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 01 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. The rAF callback itself is negligible, but the PerformanceObserver handler 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: compositetransform and opacity transitions stay entirely on the compositor thread when will-change is 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-thread for the read/write operations; transitions subsequently promoted to composite if only transform/opacity are 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.

  1. 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.

  2. 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.

  3. Record a trace while interacting with the animated element. Aim for 3–5 seconds of animation.

  4. 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 requestAnimationFrame callback — the classic read/write interleave pattern.
    • Update Layer Tree entries that are unexpectedly large — may indicate over-promotion from excessive will-change.
  5. 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.

  6. 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.

  7. 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 nearby will-change declarations.


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.