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:
compositefor each card’s steady-state motion; the onlymain-threadwork 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:
compositeonly;animation-delayshifts 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
requestAnimationFramefires 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
animationendas 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/opacityfirst. - Reduced-motion users must never see the stagger; jumping to the end state is the correct behaviour, not a faster stagger.
Related
- Frame Budgeting & 16ms Targets — the parent guide on partitioning the 16.67ms rendering cycle
- GPU Memory & Texture Management — the VRAM ceiling that caps how many layers can stay promoted at once
- Avoiding Layout Thrashing in CSS Animations — why layout-triggering motion cannot share the frame the way compositor-only motion can