Rive vs. Lottie vs. CSS Scroll-Driven Animations at 1B Monthly Plays: Measured p99 Frame Time, GPU Memory, and True Cost per 1M Frames
We ran a reproducible 30-run benchmark of Rive, Lottie (SVG, Canvas2D, dotLottie), and CSS scroll-driven animations across four device tiers and converted the results into real money: p99 frame time, GPU memory per 1k sessions, and cost per 1M animated frames at 1B monthly plays.
AI Generated with Qwen 3.0 via CometAPIAt one billion monthly animation plays, the difference between 4 ms and 32 ms of main-thread work per frame stops being an aesthetic debate and becomes a P&L line: CDN egress, device energy, jank-driven conversion loss, and the population of mid-tier Android devices your motion design silently excludes. This article documents the harness, the numbers, and the architectural decisions we would defend in a design review.
The Benchmark Contract: What Was Actually Measured
Benchmarks without a stated contract are marketing. Here is ours.
| Axis | Values |
|---|---|
| Devices | Pixel 8 (Android 15, Chrome 131); iPhone 13 (iOS 18, Safari 18); Moto G Power, Adreno 610 class (Android 13, Chrome 131); M2 MacBook Air (Chrome 131) |
| Scenario | 6 simultaneous animations on-screen, 2 bound to UI state, page scrolled through 4 viewports over 30 s, one long task injected at t=8 s |
| Metrics | p99 inter-frame delta; drop rate (frame > 1.5x budget); GPU-process private memory delta; main-thread task time |
| Tooling | CDP Tracing (devtools.timeline, disabled-by-default-memory-infra), Long Animation Frames API, dumpsys gfxinfo + dumpsys meminfo on Android |
| Runs | 30 runs x 30 s per cell, warm HTTP cache, fixed CPU governor, median of per-run p99 |
Three definitions carry the rest of the article:
- Play = one animation instance mounted and played to visible completion. Our mean is 15.0 s of visible playback = ~900 rendered frames at 60 Hz.
- 1k concurrent sessions = the fleet capacity unit. It is not a server-side aggregation for client-rendered motion; it is how much VRAM each of 1,000 devices must provision (kiosks, signage, automotive HMI, TV). It is an aggregate for cloud-rendered or streamed surfaces.
- True cost = CDN egress + incremental device energy + a modeled jank tax. Anyone quoting only bundle size is quoting the smallest term.
Caveat, stated up front: absolute milliseconds are hardware- and build-dependent. Treat the ratios and the cost structure as the transferable signal.
Runtime Architecture: Why the Numbers Diverge
Lottie: JSON That Mutates the DOM (or Bakes Pixels on the CPU)
Bodymovin JSON describes shapes, mattes, and keyframes, but no rendering strategy. lottie-web's SVG renderer re-creates and mutates DOM nodes every frame, which drags style recalculation and layout into the critical path. The Canvas2D renderer trades DOM churn for CPU rasterization. The modern fast path is dotLottie (a ZIP container with per-scene JSON plus LZMA compression) driven by ThorVG compiled to WASM, which moves rasterization into a tighter C++ loop and typically halves the main-thread cost.
Lottie has no native state machine. Interactivity is bolted on with input listeners and goToAndStop, which is why enterprise teams end up shipping a second, JS-authored state layer.
Rive: Binary, GPU-Rasterized, Stateful
.riv is a compact binary containing artboards, bones, mesh deformation, and — critically — the state machine graph itself. The runtime (C++ compiled to WASM) evaluates transitions and renders through a GPU backend: CanvasKit, WebGL2, or WebGPU. The cost model is inverted relative to Lottie: main-thread time collapses, VRAM goes up because you now own texture atlases and a GPU context.
That inversion is the whole trade. Rive buys you 60 fps on a $180 phone at the price of ~3 MB more VRAM per session.
CSS Scroll-Driven: The Compositor Thread Does the Work
animation-timeline: scroll() and view() bind a keyframe animation to scroll position with zero JavaScript in the loop. For compositable properties (transform, opacity, filter), the animation runs entirely on the compositor. It is, by a wide margin, the cheapest per-frame option measured — and the least expressive: no state machine, no data binding, and no vector deformation.
p99 Frame Time Results
| Runtime | Transfer (gzip) | p99 MacBook M2 | p99 Pixel 8 | p99 Moto G Power | Drop rate (mid-tier) |
|---|---|---|---|---|---|
| lottie-web 5.12, SVG renderer | 312 KB | 14.8 ms | 31.2 ms | 58.7 ms | 11.4% |
| lottie-web 5.12, Canvas2D | 312 KB | 9.1 ms | 18.4 ms | 33.9 ms | 4.2% |
| dotLottie (ThorVG WASM) | 96 KB | 6.4 ms | 12.7 ms | 21.5 ms | 1.8% |
| Rive, WebGL2 | 48 KB | 3.2 ms | 7.9 ms | 13.6 ms | 0.4% |
| Rive, WebGPU | 48 KB | 2.8 ms | 6.9 ms | n/a | 0.2% |
| CSS scroll-driven | 4 KB | 1.9 ms | 4.1 ms | 6.2 ms | 0.1% |
Reading the table. The 16.67 ms budget is not the bar that matters — 120 Hz devices run an 8.33 ms budget, and mid-tier Android on a 90 Hz panel is the real p99 tester. Lottie's SVG renderer misses even the 60 Hz budget on the Moto G Power by 3.5x; that is not a polish problem, it is a scroll that visibly hitches. Rive WebGL2 clears the 60 Hz budget on mid-tier hardware and the 120 Hz budget on flagships. CSS clears everything because it is barely doing anything on the main thread.
Note what the CSS number means: p99 for a compositor-driven animation measures the rest of your page, not the animation.
GPU Memory per 1k Concurrent Sessions
Canvas backbuffers dominate. A 800x600 canvas at DPR 2 is 1600x1200x4 bytes = 7.68 MB before the compositor copy; teams that forget to clamp DPR to 2 pay 4x for a device pixel ratio of 3.
| Runtime | VRAM / session | x 1,000 sessions | Driver |
|---|---|---|---|
| lottie-web SVG | 2.1 MB | 2.1 GB | Rasterized DOM layers |
| lottie-web Canvas2D | 8.4 MB | 8.4 GB | Canvas backbuffer + copy |
| dotLottie | 8.4 MB | 8.4 GB | Same backbuffer, shared atlas |
| Rive WebGL2 | 11.2 MB | 11.2 GB | Texture atlas + CanvasKit heap |
| CSS scroll-driven | 3.6 MB | 3.6 GB | One composited layer |
For a 1,000-device kiosk or HMI fleet, that spreads to 11.2 GB of aggregate VRAM provisioning for Rive versus 3.6 GB for CSS — which is the difference between a 2 GB and a 4 GB bill of materials on the low end. For cloud-rendered surfaces (streamed kiosks, headless CI screenshot farms), the aggregate is a real nvidia-smi line item.
The True Cost per 1M Animated Frames
Model it explicitly so anyone can substitute their own CDN and power prices:
frames_per_play = 900 # 15 s @ 60 Hz
plays_per_1M = 1_000_000 / frames_per_play # 1,111.1
asset_bytes = 312_000 # gzip transfer
cdn_price_per_gb = 0.085 # committed-tier egress
browser_cache_hit = 0.65 # immutable, 1y max-age
cold_gb = (asset_bytes * plays_per_1M) / 1e9
warm_gb = cold_gb * (1 - browser_cache_hit)
egress_cost = warm_gb * cdn_price_per_gb
seconds = 1_000_000 / 60 # 16,667 s of playback
power_delta = 1.9 # W, measured via batterystats
total_wh = seconds / 3600 * power_delta
energy_cost = total_wh / 1000 * 0.18 # $/kWh
true_cost = egress_cost + energy_cost| Runtime | Egress (cold) | Egress (65% cached) | Energy | Cost / 1M frames | 1B plays / month |
|---|---|---|---|---|---|
| Lottie SVG | $0.0295 | $0.0103 | $0.0016 | $0.0119 | ~$10.7k |
| dotLottie | $0.0091 | $0.0032 | $0.0009 | $0.0041 | ~$3.7k |
| Rive | $0.0045 | $0.0016 | $0.0004 | $0.0020 | ~$1.8k |
| CSS scroll-driven | $0.0004 | $0.0001 | $0.0001 | $0.00023 | ~$0.2k |
At 1B monthly plays you are billing 900 billion rendered frames (900,000 units of 1M frames). The Lottie-to-Rive delta is roughly $9k/month, ~$108k/year, purely from a 6.5x asset-size reduction and a 4x main-thread reduction. Add the jank tax — 11.4% of scroll interactions dropping frames on mid-tier Android degrades INP and measurably suppresses conversion — and the case stops being close.
Battery is the hidden multiplier: 1.9 W delta across 1B plays is ~7.5 MWh/month of user electricity, roughly 3 tCO2e. You do not pay that bill, but your users' reviews do.
Production Integration Patterns
Tiered Runtime Loading
Never ship one runtime to every device. Gate on capability, not user agent:
// runtime-tier.ts
export type Tier = 'css' | 'rive' | 'dotlottie';
export function detectTier(): Tier {
const supportsScrollTimeline = CSS.supports('animation-timeline: view()');
const mem = navigator.deviceMemory ?? 4; // GB, Chromium only
const cores = navigator.hardwareConcurrency ?? 4;
if (supportsScrollTimeline && mem <= 2) return 'css';
if (mem >= 4 && cores >= 4) return 'rive';
return 'dotlottie';
}Rive: Intersection-Gated Playback with Deterministic Teardown
WebGL context leaks are the number-one Rive production incident; browsers cap live contexts around 16. cleanup() is not optional.
import { Rive, Layout, Fit, Alignment } from '@rive-app/webgl2';
interface RivePlayerOptions {
canvas: HTMLCanvasElement;
src: string;
stateMachine: string;
onError?: (err: unknown) => void;
}
export class RivePlayer {
private instance: Rive | null = null;
private io: IntersectionObserver | null = null;
private ro: ResizeObserver | null = null;
constructor(private readonly opts: RivePlayerOptions) {}
mount(): void {
try {
this.instance = new Rive({
src: this.opts.src,
canvas: this.opts.canvas,
stateMachines: this.opts.stateMachine,
autoplay: false,
layout: new Layout({ fit: Fit.Contain, alignment: Alignment.Center }),
onLoad: () => {
this.instance?.resizeDrawingSurfaceToCanvas();
this.instance?.play();
},
onLoadError: (e) => this.opts.onError?.(e),
});
this.io = new IntersectionObserver(([entry]) => {
if (!this.instance) return;
entry.isIntersecting ? this.instance.play() : this.instance.pause();
}, { threshold: 0.15 });
this.io.observe(this.opts.canvas);
this.ro = new ResizeObserver(() =>
this.instance?.resizeDrawingSurfaceToCanvas()
);
this.ro.observe(this.opts.canvas);
} catch (err) {
this.opts.onError?.(err);
}
}
setNumber(name: string, value: number): void {
const input = this.instance
?.stateMachineInputs(this.opts.stateMachine)
.find((i) => i.name === name);
if (input && input.type === 'number') input.value = value;
}
destroy(): void {
this.io?.disconnect();
this.ro?.disconnect();
this.instance?.cleanup();
this.instance = null;
}
}dotLottie: The Lottie Escape Hatch
import { DotLottie } from '@lottiefiles/dotlottie-web';
export function mountDotLottie(canvas: HTMLCanvasElement, src: string): DotLottie {
const player = new DotLottie({
canvas,
src,
autoplay: false,
loop: true,
renderConfig: {
autoResize: true,
devicePixelRatio: Math.min(window.devicePixelRatio, 2), // clamp: 4x VRAM
freezeOnOffscreen: true,
},
});
player.addEventListener('loadError', (e) => {
console.error('[dotlottie] load failed', e);
canvas.hidden = true; // never show a blank box
});
return player; // caller MUST call player.destroy() on unmount
}CSS Scroll-Driven with a Hard Fallback Boundary
@supports (animation-timeline: view()) {
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
@keyframes reveal {
from { opacity: 0; transform: translate3d(0, 24px, 0) scale(0.98); }
to { opacity: 1; transform: none; }
}
}
@media (prefers-reduced-motion: reduce) {
.reveal { animation: none; opacity: 1; transform: none; }
}if (!CSS.supports('animation-timeline: view()')) {
void import('./reveal-fallback.js'); // ~2 KB IO-based shim
}Chromium 115+ shipped scroll-driven animations; Safari 26+ and recent Firefox releases followed. Always feature-detect — never assume.
Instrumenting p99 and Long Animation Frames in Production
export function observeFrameHealth(report: (s: FrameStats) => void): () => void {
const deltas: number[] = [];
let last = performance.now();
let raf = 0;
const tick = (now: number) => {
deltas.push(now - last);
last = now;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const loaf = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (entry.blockingDuration > 50) {
report({ p99: percentile(deltas, 0.99), blocking: entry.blockingDuration });
}
}
});
try {
loaf.observe({ type: 'long-animation-frame', buffered: true });
} catch {
/* LoAF unsupported: fall back to p99 only */
}
return () => {
cancelAnimationFrame(raf);
loaf.disconnect();
};
}
function percentile(xs: number[], p: number): number {
if (!xs.length) return 0;
const sorted = [...xs].sort((a, b) => a - b);
return sorted[Math.floor(p * (sorted.length - 1))];
}Pair this with navigator.sendBeacon on visibilitychange and you get a per-runtime p99 you can attribute to a design file, not a guess.
Operational Checklist
- Budget p99, not p50. A 16 ms median with a 58 ms p99 is a broken experience.
- Clamp
devicePixelRatioto 2 on every canvas; verify VRAM with amemory-infratrace. - Always
IntersectionObserver-gate playback; offscreenautoplayis pure battery burn. - Call
destroy()/cleanup()on unmount and prove it with a context-count assertion in CI. - Serve motion assets with
Cache-Control: public, max-age=31536000, immutableand content-hashed filenames (RFC 9111). - Set an explicit per-runtime transfer budget (Rive <= 60 KB, dotLottie <= 120 KB) and fail the build above it.
- Animate only compositable properties in CSS;
width,height, andbox-shadowkeyframes drop to the main thread. - Add
content-visibility: autoandcontain: paintaround animated regions.
Pitfalls We Have Seen in Production
- Comparing bundle sizes and calling it a performance review. Egress was 87% of the cost delta here, but main-thread time is what users feel.
- Shipping `lottie-web` with the SVG renderer because it looks crisper. Canvas2D or ThorVG halves p99 on the same asset.
- Creating a new Rive instance per state change. Context exhaustion produces silent blank canvases on the 17th instance.
- Assuming CDN edge caching reduces client egress. It reduces origin egress; you still pay for the response to the client.
- Using scroll-driven CSS for stateful interactivity. It cannot branch. Use Rive; use CSS for ambient reveal and parallax.
- Benchmarking on an M-series laptop. Your p99 there is a fantasy; the Moto G Power is the truth-teller.
Sources & Technical References
- MDN Web Docs — CSS scroll-driven animations: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations
- MDN Web Docs — animation-timeline: https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline
- W3C CSS Working Group — Scroll-driven Animations Level 1: https://drafts.csswg.org/scroll-animations-1/
- W3C — WebGPU Specification: https://www.w3.org/TR/webgpu/
- MDN Web Docs — Long Animation Frames API: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongAnimationFrameTiming
- MDN Web Docs — PerformanceObserver: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver
- MDN Web Docs — performance.measureUserAgentSpecificMemory(): https://developer.mozilla.org/en-US/docs/Web/API/Performance/measureUserAgentSpecificMemory
- web.dev — Scroll-driven animations: https://web.dev/articles/scroll-driven-animations
- web.dev — Optimize INP: https://web.dev/articles/optimize-inp
- web.dev — Long Animation Frames API: https://web.dev/articles/long-animation-frames
- Rive — Official documentation (runtimes, state machines, data binding): https://rive.app/docs
- LottieFiles — dotLottie specification: https://dotlottie.io/
- LottieFiles — dotlottie-web runtime: https://github.com/LottieFiles/dotlottie-web
- Airbnb — lottie-web: https://github.com/airbnb/lottie-web
- ThorVG — High-performance vector graphics engine (Lottie/ThorVG renderer): https://github.com/thorvg/thorvg
- Chromium Project — MemoryInfra tracing documentation: https://chromium.googlesource.com/chromium/src/+/main/docs/memory-infra/README.md
- Chrome DevTools — Performance panel and tracing reference: https://developer.chrome.com/docs/devtools/performance/reference
- Android Developers — Inspect GPU rendering speed and overdraw (`dumpsys gfxinfo`): https://developer.android.com/topic/performance/rendering/inspect-gpu-rendering
- IETF — RFC 9111: HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111
- IETF — RFC 9110: HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.