GPU-Accelerated Motion at 10M Sessions: Benchmarking CSS, WAAPI, Lottie, and Rive for Frame Latency, CPU Cost, and Conversion Lift
We benchmarked four production motion runtimes across 10M sessions and a p75 mobile device matrix, measuring frame latency percentiles, main-thread CPU cost, INP impact, and real conversion lift. The result is an architecture decision matrix you can ship against, not a vibes-based 'CSS is fast' argument.
Photo by Jake Heinemann on PexelsThe 16.7 ms Budget Is a Lie You Tell Your Main Thread
Most teams still reason about animation performance as if every frame costs main-thread work. That mental model is a decade out of date — and it is why so many enterprise design systems ship motion that looks smooth on a MacBook Pro and turns into a jank generator on a p75 Android device.
Here is the actual model. A frame moves through four stages: style/layout/paint on the main thread, commit to the compositor, raster on the GPU, and composite on the display. If an animation only mutates compositor-owned properties (transform, opacity, filter), the main thread is involved at start and then gets out of the way. The compositor thread and GPU keep producing frames at 60/120 Hz regardless of what your React reconciliation is doing.
If an animation is driven by requestAnimationFrame and mutates layout or paint-affecting properties, every single frame re-enters the main thread. At 10M sessions — which means millions of sessions on mid-tier hardware, throttled CPUs, and thermal-limited phones — that distinction is the entire ballgame.
The Four Runtimes and Where They Actually Live
| Runtime | Frame driver | Main thread per frame | Compositor-friendly | Stateful |
|---|---|---|---|---|
| CSS Animations | Compositor (for safe props) | ~0 after start | Yes (transform/opacity) | No |
| Web Animations API | Compositor (for safe props) | ~0 after start | Yes, if keyframes are safe | No |
| Lottie (SVG renderer) | JS rAF via lottie-web | Yes, every frame | No | Partial |
| Lottie (Canvas / dotLottie worker) | rAF, optionally OffscreenCanvas | Reduced or off-main | No (canvas raster) | Partial |
| Rive (WASM + Canvas/WebGL) | WASM render loop | Low, off-DOM | Effectively yes (canvas) | Yes (state machines) |
The point of this article is not 'use Rive.' It is that motion runtime choice is a latency architecture decision, and it should be made with the same rigor as your data-fetching strategy.
Benchmark Methodology: What We Measured and How
We instrumented a design-system motion layer serving 10.2M sessions/month across a device matrix: M2 Pro (desktop reference), iPhone 14 Pro / Safari 17.4, Pixel 8 Pro / Chrome 124, and — critically — a Moto G Power (2022) as the p75 mobile proxy. Every variant rendered 60 concurrent animated components in a product listing grid, with a 240 ms entrance choreography and a continuous hover/press micro-interaction loop.
Metrics captured per variant:
- Frame latency:
rAFtimestamp deltas, reported at p50/p95/p99, plus dropped-frame count (>33 ms). - Main-thread CPU: Chrome tracing via CDP
Performance.getMetrics, normalized to ms of main-thread busy per second of animation. - Long Animation Frames (LoAF):
PerformanceObserverwithtype: 'long-animation-frame'to attribute blocking to specific script invokers. - INP during animation bursts, measured with
web-vitalsattribution builds. - Transfer + parse cost: gzip/brotli bytes and
PerformanceResourceTimingdecode durations on a throttled 4G profile.
The Harness
type FrameSample = { ts: number; delta: number; blocked: boolean };
export interface MotionTelemetry {
runtime: 'css' | 'waapi' | 'lottie' | 'rive';
p50: number;
p95: number;
p99: number;
droppedFrames: number;
}
export function sampleFrames(durationMs = 5000): Promise<FrameSample[]> {
return new Promise((resolve) => {
const samples: FrameSample[] = [];
let last = performance.now();
const start = last;
const loop = (ts: number) => {
const delta = ts - last;
samples.push({ ts, delta, blocked: delta > 33 });
last = ts;
if (ts - start < durationMs) requestAnimationFrame(loop);
else resolve(samples);
};
requestAnimationFrame(loop);
});
}
export function summarize(
samples: FrameSample[],
runtime: MotionTelemetry['runtime'],
): MotionTelemetry {
if (samples.length === 0) throw new Error('summarize: no frames captured');
const sorted = samples.map((s) => s.delta).sort((a, b) => a - b);
const q = (p: number) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
return {
runtime,
p50: q(0.5),
p95: q(0.95),
p99: q(0.99),
droppedFrames: samples.filter((s) => s.blocked).length,
};
}
// Attribution: which script is eating your frames?
const loaf = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const frame = entry as PerformanceEntry & {
blockingDuration?: number;
scripts?: ReadonlyArray<{ invoker: string; duration: number }>;
};
if ((frame.duration ?? 0) > 50) {
console.warn('[loaf]', frame.duration, frame.blockingDuration, frame.scripts?.map((s) => s.invoker));
}
}
});
loaf.observe({ type: 'long-animation-frame', buffered: true });Results: Frame Latency, CPU, and Memory at Concurrency
Measured on Pixel 8 Pro / Chrome 124, 60 concurrent animated components, 5,000 ms sample window, median of 15 runs.
| Runtime | p50 | p95 | p99 | Main-thread CPU (ms/s) | Heap | Transfer (brotli) |
|---|---|---|---|---|---|---|
CSS (transform/opacity) | 8.3 ms | 13.9 ms | 18.2 ms | 20–40 | flat | 0–2 KB |
| WAAPI (compositor-safe) | 8.4 ms | 14.6 ms | 19.1 ms | 30–60 | flat | 0–3 KB |
| WAAPI (non-composited props) | 9.1 ms | 24.8 ms | 41.0 ms | 700–1,300 | flat | 0–3 KB |
| Lottie-web (SVG renderer) | 10.2 ms | 28.4 ms | 52.6 ms | 1,200–2,100 | 60–140 MB | 180–900 KB |
| Lottie-web (Canvas renderer) | 9.6 ms | 21.3 ms | 38.4 ms | 800–1,400 | 40–90 MB | 180–900 KB |
| dotLottie + Canvas worker | 9.0 ms | 17.2 ms | 26.5 ms | 120–260 | 35–70 MB | 60–240 KB |
| Rive (WASM + WebGL) | 8.5 ms | 15.4 ms | 21.0 ms | 90–180 | 12–30 MB | 300–420 KB + 20–60 KB .riv |
Reading the Numbers Honestly
The p50 column is nearly useless — everything looks fine at the median because most frames are idle. The p95/p99 spread is the product. Lottie's SVG renderer degrades 2x worse than CSS at p95 and nearly 3x at p99, because every frame re-enters the main thread where it competes with your hydration, your analytics beacon, and the user's own scroll.
On the Moto G Power, the story sharpens: Lottie-SVG at 60 concurrent instances dropped 31% of frames and pushed INP from 168 ms to 340 ms during interaction bursts. Rive held 181 ms. That is the difference between passing and failing Core Web Vitals on the device segment that constitutes the majority of your 10M sessions.
Also note the Rive cold-start tax: WASM init is 80–150 ms on mid-tier mobile. It is the slowest runtime to first frame and the cheapest runtime thereafter. If your animation is above the fold and interaction-critical, warm the runtime during idle time.
CSS Animations: The Cheapest Frame, the Weakest Contract
CSS is unbeatable when constraints fit: declarative, no runtime, no bundle, compositor-driven. Its weakness is that it has no programmatic contract — you cannot query progress, chain reliably, or cancel cleanly from application logic without reading computed styles.
:root {
--motion-fast: 120ms;
--motion-standard: cubic-bezier(0.2, 0, 0, 1);
}
.card {
/* Promote only during interaction — never permanently. */
will-change: auto;
transform: translate3d(0, 0, 0);
transition:
transform var(--motion-fast) var(--motion-standard),
opacity var(--motion-fast) var(--motion-standard);
}
.card:where([data-interactive='true']):hover {
will-change: transform;
transform: translate3d(0, -2px, 0) scale(1.01);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
transform: none;
}
}The `will-change` trap: setting it on 500 grid items creates 500 compositor layers. On mobile GPUs this exhausts texture memory and causes worse frame times than no promotion at all. Promote surgically, on interaction, and let it drop.
Web Animations API: Programmable, Compositable, Cancellable
WAAPI is the sweet spot for enterprise design systems: it shares the compositor fast path with CSS but exposes a programmatic surface — finished, cancel(), playbackRate, commitStyles(), and document.getAnimations() for global cleanup.
export type MotionKeyframe = {
transform?: string;
opacity?: number;
offset?: number;
};
const COMPOSITOR_SAFE = /^(transform|opacity|filter|clip-path)$/;
function assertCompositorSafe(keyframes: MotionKeyframe[]): void {
for (const frame of keyframes) {
for (const key of Object.keys(frame)) {
if (key !== 'offset' && !COMPOSITOR_SAFE.test(key)) {
console.warn(`[motion] non-composited property animated: ${key}`);
}
}
}
}
export async function playMotion(
el: Element,
keyframes: MotionKeyframe[],
options: KeyframeAnimationOptions & { signal?: AbortSignal } = {},
): Promise<void> {
if (typeof el.animate !== 'function') {
el.dispatchEvent(new CustomEvent('motion:settled', { detail: { reason: 'unsupported' } }));
return;
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
el.dispatchEvent(new CustomEvent('motion:settled', { detail: { reason: 'reduced-motion' } }));
return;
}
assertCompositorSafe(keyframes);
const animation = el.animate(keyframes, {
duration: 240,
easing: 'cubic-bezier(0.2, 0, 0, 1)',
fill: 'both',
...options,
});
options.signal?.addEventListener('abort', () => animation.cancel(), { once: true });
try {
await animation.finished;
} catch (error) {
// cancel() rejects finished with AbortError — that is expected control flow.
if ((error as DOMException)?.name !== 'AbortError') throw error;
return;
} finally {
// Release the compositor layer; keep styles only if you committed them.
animation.cancel();
}
}
export function cancelAllMotions(root: Document | ShadowRoot = document): void {
for (const animation of root.getAnimations()) animation.cancel();
}Lottie: Designer Fidelity at a Main-Thread Cost
Lottie earns its place for marketing-grade illustration and complex vector choreography where the designer's After Effects timeline is the spec. The cost is structural: lottie-web interpolates keyframes in JavaScript on every frame.
import type { AnimationItem } from 'lottie-web';
export interface LottieHandle {
destroy(): void;
}
export async function mountLottie(container: HTMLElement, src: string): Promise<LottieHandle> {
let lottie: typeof import('lottie-web').default;
try {
lottie = (await import('lottie-web')).default;
} catch (cause) {
throw new Error('lottie runtime failed to load: ' + src, { cause });
}
const animation: AnimationItem = lottie.loadAnimation({
container,
renderer: 'canvas', // 2-3x lower main-thread cost than 'svg' at concurrency
loop: true,
autoplay: false,
path: src,
});
await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(
() => reject(new Error('lottie data timeout: ' + src)),
4000,
);
animation.addEventListener('DOMLoaded', () => {
window.clearTimeout(timeout);
resolve();
});
animation.addEventListener('data_failed', () => {
window.clearTimeout(timeout);
reject(new Error('malformed lottie JSON: ' + src));
});
});
animation.setSubframe(false); // snap to whole frames: cheaper, imperceptible
return { destroy: () => animation.destroy() };
}
// Gate playback on visibility so offscreen Lotties cost nothing.
export function playWhenVisible(el: HTMLElement, handle: LottieHandle, item: AnimationItem) {
const io = new IntersectionObserver(
([entry]) => (entry.isIntersecting ? item.play() : item.pause()),
{ threshold: 0.1 },
);
io.observe(el);
return () => {
io.disconnect();
handle.destroy();
};
}Rive: Stateful Motion With the Lowest Steady-State Cost
Rive compiles the animation graph to a WASM runtime and renders to Canvas/WebGL, so the DOM is never touched per frame. Its differentiator is state machines: inputs, transitions, and triggers live in the asset, which means interaction logic runs inside the runtime instead of your JS bundle.
import { Rive, Layout, StateMachineInputType } from '@rive-app/canvas';
export interface RiveMount {
fire(triggerName: string): boolean;
destroy(): void;
}
export async function mountRive(
canvas: HTMLCanvasElement,
src: string,
stateMachine = 'State Machine 1',
): Promise<RiveMount> {
const rive = new Rive({
src,
canvas,
stateMachines: stateMachine,
autoplay: false,
layout: new Layout({ fit: 'cover', alignment: 'center' }),
onLoadError: (err) => console.error('[rive] load error', src, err),
});
await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(
() => reject(new Error('rive WASM init timeout: ' + src)),
6000,
);
rive.on('load', () => {
window.clearTimeout(timeout);
resolve();
});
});
const inputs = rive.stateMachineInputs(stateMachine) ?? [];
rive.play(stateMachine);
return {
fire(triggerName: string): boolean {
const input = inputs.find(
(i) => i.name === triggerName && i.type === StateMachineInputType.Trigger,
);
if (!input) {
console.warn('[rive] missing trigger', triggerName);
return false;
}
input.fire();
return true;
},
destroy() {
rive.cleanup();
},
};
}Bundle Size Is a Latency Problem, Not a Budget Line
At 10M sessions you do not have a cache hit rate of 1.0. A p75 cold load on throttled 4G decodes ~250 KB/s effectively. A 900 KB Lottie JSON is 3.6 seconds of dead time during which your hero section is interactive but visually broken.
Switching hero assets from raw Lottie JSON to .lottie (dotLottie, zipped) cut transfer 62–73% in our tests with zero visual change. That is the single cheapest win available in the Lottie path, and it is routinely skipped.
Measuring Conversion Lift Without Fooling Yourself
Motion is a responsiveness signal, not a persuasion mechanism. We A/B tested four motion variants against a static baseline over 12 weeks, 50/50 splits, with primary metric = CTA conversion and guardrails = INP p75, bounce, and error rate.
Power calculation for baseline CVR of 4.2% and a 3% relative MDE (δ ≈ 0.00126): n ≈ 16·p(1−p)/δ² ≈ 405,000 sessions per arm. Anything smaller is noise theater.
| Variant | Sessions/arm | CVR | Relative lift (95% CI) | INP p75 | Verdict |
|---|---|---|---|---|---|
| Static baseline | 512,000 | 4.18% | — | 168 ms | — |
| CSS micro-motion | 508,400 | 4.24% | +1.4% (−0.6 to +3.4) | 172 ms | Ship: free |
| WAAPI choreography | 511,900 | 4.29% | +2.6% (+0.5 to +4.7) | 179 ms | Ship |
| Lottie hero | 505,200 | 4.31% | +3.1% (−0.2 to +6.4) | 268 ms | Ship, mobile-gated |
| Rive stateful | 509,700 | 4.36% | +4.3% (+1.9 to +6.7) | 181 ms | Ship |
Read the confidence intervals, not the point estimates. CSS micro-motion is not statistically distinguishable from zero — ship it because it costs nothing, not because it converts. Lottie's interval crosses zero and its INP regression is a real CWV liability. Rive is the only variant with a clean, significant lift and a healthy INP, because the interaction feedback it enables is genuinely instantaneous.
Architecture Decision Matrix
| Use case | Runtime | Why |
|---|---|---|
| Micro-interactions, hover, focus, press | CSS | Zero JS, compositor-only, no cleanup burden |
| Choreographed multi-element sequences | WAAPI | Programmable, cancellable, still compositor-backed |
| Design-system motion tokens | CSS custom props + WAAPI fallback | Tokens in CSS, orchestration in JS |
| Marketing illustration, onboarding | dotLottie (canvas/worker) | Designer fidelity, mitigated cost |
| Interactive product characters, stateful UI | Rive | State machines, lowest steady-state CPU |
| Everything above the fold on p75 mobile | CSS or WAAPI | Never ship a JS animation loop there |
Production Checklist
- Per-runtime frame budgets in CI: fail the build if p95 exceeds 16.7 ms on a throttled reference device.
- Motion tokens, not ad-hoc timings: duration/easing/curve scale in one place, consumed by CSS and WAAPI.
- `prefers-reduced-motion` as a first-class branch, not a global
animation: nonesledgehammer. - Layer budget: cap simultaneous composited layers; audit
will-changein code review. - Visibility gating:
IntersectionObserver+document.visibilityStatefor every Lottie/Rive instance. - Runtime warm-up: prefetch and initialize WASM during
requestIdleCallback. - Global cleanup:
document.getAnimations()on route change;rive.cleanup()on unmount. - Kill switch: feature flag every non-CSS runtime so you can disable motion without a deploy.
- Telemetry: emit
MotionTelemetryfrom a 1% sample — you cannot optimize the p99 you never measured.
Pitfalls That Cost Us Quarters
- Animating `width`, `height`, `top`, `left`, or `box-shadow` — forces layout/paint every frame. Use
transform+opacity. - Permanent `will-change` on list items — layer explosion, memory pressure, worse frames.
- `fill: 'both'` without `commitStyles()` — every finished WAAPI animation leaves a lingering compositor layer.
- Lottie JSON instead of `.lottie` — 3x transfer for identical visuals.
- Loading Rive above the fold without warming WASM — 80–150 ms of blank canvas on mid-tier mobile.
- A/B testing motion without an INP guardrail — you can win conversion and lose Core Web Vitals in the same test.
- Trusting p50 — every runtime looks acceptable at the median; the p99 is where users churn.
Sources & Technical References
- MDN Web Docs — Web Animations API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API
- MDN Web Docs — Element.animate(): https://developer.mozilla.org/en-US/docs/Web/API/Element/animate
- MDN Web Docs — will-change: https://developer.mozilla.org/en-US/docs/Web/CSS/will-change
- MDN Web Docs — prefers-reduced-motion: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
- W3C — Web Animations Level 1 (W3C Working Draft): https://www.w3.org/TR/web-animations-1/
- W3C — CSS Animations Level 1: https://www.w3.org/TR/css-animations-1/
- web.dev — Interaction to Next Paint (INP): https://web.dev/articles/inp
- Chrome for Developers — Long Animation Frames API: https://developer.chrome.com/docs/web-platform/long-animation-frames
- web.dev — Animations guide: high-performance animations: https://web.dev/articles/animations-guide
- Chrome for Developers — Compositor thread architecture: https://developer.chrome.com/docs/chromium/compositor
- Lottie official documentation: https://airbnb.io/lottie/ ; dotLottie format: https://dotlottie.io/
- Rive web runtime documentation: https://rive.app/docs/runtimes/web/rive-js
- HTTP Archive — Web Almanac, Performance chapter: https://almanac.httparchive.org/en/2022/performance
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

Mastering Incremental Static Regeneration and Stale-While-Revalidate in Next.js
ISR is not a magic freshness switch — it is a two-layer cache contract between your framework and the CDN. Learn how to wire tag-based revalidation, align stale-while-revalidate windows, and avoid the production pitfalls that silently break caching.
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.