Budgeting Concurrent Animations Per Frame

Part of Frame Budgeting & 16ms Targets in Performance Budgeting & GPU Architecture.

The problem: motion that stacks onto a single frame

A grid of forty cards each fades and slides in on load. Individually every animation is compositor-safe transform and opacity, so each one is cheap in steady state. Start all forty on the same frame, though, and that frame must promote forty elements, upload forty textures, and schedule forty compositing operations before the display’s next refresh. The result is a hitch on the first frame followed by smooth motion — the classic signature of concurrency that ignored the budget. The question is not “is this animation cheap?” but “how much of everything happens on the same frame?”

Root cause: the frame budget is shared, not per-animation

Every animation draws from the same 16.67ms budget partitioned across the pipeline: roughly 8–10ms of main-thread JavaScript, style and layout, and under 4ms of compositor overhead on the GPU thread. Two costs decide how many animations fit:

  • Per-frame steady-state cost. A compositor-only animation, once promoted, costs the compositor a small, near-fixed amount to re-blend its texture with a new transform matrix. These are cheap and parallel-friendly, so many can share the compositor’s per-frame slice — provided their textures fit VRAM, which is governed by GPU memory & texture management.
  • Start-of-animation cost. The frame on which an animation begins is special: the element is promoted to a layer and its bitmap is rasterised and uploaded to the GPU. That upload is bandwidth-bound and lands on or synchronised with the compositor. Forty starts on one frame is forty uploads on one frame.

Animations that touch layout or paint are worse: each one re-enters the main-thread pipeline every frame, competing directly for the 8–10ms slice. A single width or top keyframe in the mix can starve the budget on its own — the mechanism is covered under avoiding layout thrashing in CSS animations.

So the budget breaks in two ways: too many starts on one frame (upload spike) or too many layout-triggering animations at any time (main-thread saturation).

Decision matrix: run together, stagger, or cap

Classify your animation, then read across.

Animation profile Per-frame cost Concurrency approach Practical ceiling
transform / opacity, small elements Compositor only, tiny Run together; watch total layer memory Dozens, bounded by VRAM
transform / opacity, large elements Compositor + big upload on start Stagger the starts to spread uploads ~6–10 starts per frame
Many elements entering on load Upload burst on one frame Stagger with a per-item delay 3–5 new starts per frame
Animates filter: blur / drop-shadow Paint each frame Cap hard; treat as main-thread 1–2 at a time
Animates width / top / margin Layout each frame Refactor to transform; otherwise cap to one 1
Infinite ambient loops Compositor, but permanent Cap the count; demote when off-screen A few, off-screen paused

The rule of thumb: compositor-only motion is limited by memory and by start-frame uploads; anything that hits layout or paint is limited by the main-thread slice and should be counted on one hand.

Production pattern: cap and stagger a batch entry

This is the full pattern for animating a large batch on load without stacking the work. It caps how many animations start per frame and staggers the rest, keeping each frame’s promotion and upload burst small.

// Intent: reveal a batch of elements with capped, staggered starts so no single
// frame pays for more than a few layer promotions and texture uploads.

const prefersReducedMotion =
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

function revealBatch(elements) {
  // Accessibility gate first: jump straight to the end state, no staggering.
  if (prefersReducedMotion) {
    elements.forEach(el => el.classList.add('revealed'));
    return;
  }

  const STARTS_PER_FRAME = 4;   // cap promotions/uploads on any one frame
  let index = 0;

  function startNextBatch() {
    const slice = elements.slice(index, index + STARTS_PER_FRAME);

    slice.forEach(el => {
      el.style.willChange = 'transform, opacity'; // promote just before start
      el.classList.add('revealed');               // triggers the CSS animation
      // Release the texture the moment this element's motion ends.
      el.addEventListener('animationend', () => {
        el.style.willChange = 'auto';
      }, { once: true });
    });

    index += STARTS_PER_FRAME;
    if (index < elements.length) {
      // Next slice starts on a later frame — spreads the upload cost out.
      requestAnimationFrame(startNextBatch);
    }
  }

  requestAnimationFrame(startNextBatch);
}

revealBatch(Array.from(document.querySelectorAll('.card')));
/* Compositor-only entry animation; interpolation stays on the GPU thread. */
.card {
  opacity: 0;
  transform: translateY(20px);
}

.card.revealed {
  animation: card-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

@keyframes card-in {
  to { opacity: 1; transform: translateY(0); }
}

/* Reduced-motion fallback: no stagger, no motion, final state immediately. */
@media (prefers-reduced-motion: reduce) {
  .card,
  .card.revealed {
    animation: none;
    opacity: 1;
    transform: none;
    will-change: auto;
  }
}

Rendering Impact: composite for each card’s steady-state motion; the only main-thread work is the small per-frame scheduler, and promotion/upload is spread four starts per frame instead of stacking.

A pure-CSS alternative staggers with animation-delay and needs no scheduler, though it gives you less control over the exact starts-per-frame:

/* Nth-child delays spread the starts across time without JavaScript. */
@media (prefers-reduced-motion: no-preference) {
  .card { animation: card-in 0.4s ease-out both; }
  .card:nth-child(n)   { animation-delay: 0ms; }
  .card:nth-child(4n+1){ animation-delay: 60ms; }
  .card:nth-child(4n+2){ animation-delay: 120ms; }
  .card:nth-child(4n+3){ animation-delay: 180ms; }
}

@media (prefers-reduced-motion: reduce) {
  .card {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Rendering Impact: composite only; animation-delay shifts each start onto a later frame so promotion and upload never stack on the first frame.

Verification checklist

Constraints and trade-offs

  • Staggering increases the total wall-clock time of the entrance. Keep the cumulative delay under ~500ms so the sequence still feels responsive rather than sluggish.
  • A per-frame cap assumes the display runs at 60Hz. On a 120Hz screen requestAnimationFrame fires every ~8.3ms, so the same cap spreads starts over half the time — measure on the real refresh rate.
  • Capping starts helps the upload spike but does nothing for steady-state VRAM; if every element stays promoted you can still exhaust memory. Demote on animationend as shown, and lean on GPU memory & texture management for the memory budget.
  • Layout- or paint-triggering animations cannot be rescued by staggering alone — spreading their starts still leaves per-frame main-thread cost. Refactor them to transform/opacity first.
  • Reduced-motion users must never see the stagger; jumping to the end state is the correct behaviour, not a faster stagger.