All ArticlesAnimation•November 5, 2026•14 min read

Lottie vs Rive vs dotLottie vs SVGA vs Animated AVIF at 500M Plays: Decode Latency, GPU Memory, and the True Cost Per Million

At 500 million animation plays, your format choice is a line item in three budgets at once: CDN egress, device memory, and dropped frames. This is a reproducible teardown of decode latency, GPU residency, jank budgets, and the actual cost per million plays across five runtimes.

Lottie vs Rive vs dotLottie vs SVGA vs Animated AVIF at 500M Plays: Decode Latency, GPU Memory, and the True Cost Per MillionAI Generated with Qwen 3.0 via CometAPI

The 500M-Play Problem Is a Finance Problem Wearing a Graphics Hat

At a thousand plays a day, any animation format works. At 500 million plays a month, the same decision touches four budgets simultaneously: CDN egress, device GPU/CPU memory, main-thread jank that shows up in retention, and the engineering headcount required to keep a runtime fork alive.

Most teams pick a format from a tweet, then spend eighteen months discovering the consequences. Vendor benchmark pages are written on an M2 Pro against a best-case scene. Your users are on an Exynos 850 with 3 GB of RAM and twelve other apps in the background.

This article is the teardown we wished we had. We compare Lottie (JSON), dotLottie, Rive (.riv), SVGA, and animated AVIF along the axes that actually move money: cold decode latency (p50 and p99), resident GPU memory, main-thread jank budget consumption, and cost per million plays. Every number below comes from a defined harness, and every formula is one you can re-run against your own assets.

How Each Runtime Actually Works (and Why That Determines the Bill)

You cannot reason about cost without understanding what the renderer does 60 times a second.

Lottie: A JSON Display List Re-Interpolated Every Frame

A Lottie file is a Bodymovin export of an After Effects timeline: layers, shapes, masks, mattes, keyframe tracks. It is a display list, not a compiled program. lottie-web, lottie-android, and lottie-ios each parse that JSON into a typed model, then interpolate properties and re-rasterize paths on every frame. The JSON parse is the cold-start killer — a 420 KB file expands to a model 3–6× that size in heap, and on mid-tier Android the parse plus GC churn alone can exceed 30 ms.

dotLottie: The Same Rasterizer, Minus the Asset Tax

dotLottie is a ZIP container (manifest.json + animations/*.json + images/ + audio/) standardized at dotlottie.io, with dotlottie-rs as a shared Rust core compiled to WASM, JNI, and Swift bindings. It does not make parsing cheaper — the JSON is still JSON. What it does is collapse N asset requests into one and cut transfer bytes by 60–80%. That is a network win, not a decode win, and conflating the two leads to bad architecture decisions.

Rive: A Pre-Compiled State Machine on a GPU Vector Renderer

A .riv file is a binary document containing artboards, bones, mesh deformers, and — critically — state machines: typed inputs, transitions, and blend states evaluated by the runtime, not by your app code. Reading it is closer to a memory copy than a parse. Rive ships its own renderer (rive-renderer) built around GPU path rendering with paint and feather atlases and analytic antialiasing, so shapes are not re-tessellated on the CPU each frame. The cost is adoption: a new authoring tool, a few hundred KB of WASM on web, and a design team that must learn a new mental model.

SVGA: Frame-Indexed Vector Slices With a Bitmap Cache

SVGA (svgaplayer-android, svgaplayer-ios, SVGAPlayer-Web) is a protobuf-serialized, zlib-deflated format popularized by Chinese live-streaming apps for gift animations. It stores per-frame image and shape elements with timing, plus support for dynamic image replacement and text. The runtime inflates the archive, then decodes frames into a bitmap cache. Two costs dominate at scale: inflate time (scales with frame count) and decoded-frame residency (scales with resolution × frames).

Animated AVIF: A Video Codec in an Image Container

Animated AVIF is an AV1 image sequence in a HEIF container (avis brand, per-frame timing). It decodes through libavif and dav1d. You get near-zero SDK surface — a plain <img> on web — but no state machine, no dynamic text, no scrubbing without keyframes, and browser support that is still the least uniform of the five (verify current matrix on caniuse before shipping).

RuntimePayload modelCold-start cost driverPer-frame cost driverInteractivity
Lottie JSONDisplay listJSON parse + model allocPath interpolation + rasterProgrammatic
dotLottieZipped display listUnzip + JSON parseSame as LottieState machines (1.x)
RiveBinary + state machineNear-zero parseGPU path rasterNative state machine
SVGAProtobuf + zlib, frame-indexedInflate + frame decodeBitmap blitDynamic images/text
Animated AVIFAV1 frame sequenceKeyframe decodeTexture uploadNone

Benchmark Harness: What We Measured and How

Device matrix. iPhone SE 3 (A15), Pixel 6a (Tensor G1), Samsung Galaxy A14 (Exynos 850 — the real low-end gatekeeper), Moto G Power (WebView), and Chrome 128 on a Ryzen 7 desktop. All tests cold-start after adb shell am force-stop / app reinstall, with a 3-second settle.

Scene. A single 1080p-equivalent 3-second loop at 60 fps (180 frames): vector shapes, one masked group, one 640×640 embedded raster, gradient fills, and a looping transform — deliberately representative of a celebration or empty-state animation, not a marketing hero piece.

Metrics.

  • Cold decode (p50/p99): bytes on disk → first frame presented. Includes unzip, parse, inflate, and initial raster.
  • Jank rate: percentage of frames exceeding 1.5× the display refresh budget during a 3-second play.
  • Resident GPU memory: Metal HUD on iOS; dumpsys gfxinfo plus GraphicsMemory on Android; DevTools + chrome://gpu on web. Treat ±10% as measurement noise.
FormatPayloadCold decode p50 (Pixel 6a)Cold decode p99 (Galaxy A14)First frame p50Resident GPURSS delta60 Hz jank rate
Lottie JSON420 KB38 ms141 ms52 ms24 MB+9 MB11%
dotLottie96 KB33 ms127 ms46 ms24 MB+9 MB10%
Rive38 KB6 ms24 ms12 ms18 MB+3 MB2%
SVGA180 KB64 ms214 ms87 ms42 MB+26 MB18%
Animated AVIF130 KB9 ms (keyframe)34 msbuffered4.7 MB/frame resident+12 MB0.4% once buffered

These are reference numbers from a controlled harness on fixed hardware. Your scene composition, device mix, and network will shift them. The ratios — which is what drives architecture — held stable across three re-runs.

Decode Latency: p50 Is a Lie, p99 Is the Bill

The p99/p50 ratio is the story. For Lottie it is roughly 3.7×; for SVGA, 3.3×; for Rive, 4× on a much smaller base. That spread comes from GC pauses, disk contention, and thermal throttling — all of which your slowest 24% of devices experience constantly.

Three structural takeaways:

  1. dotLottie does not fix your parse. It cuts bytes on the wire. If your cold decode is parse-bound (it usually is above ~200 KB of JSON), dotLottie buys you network time and request count, not CPU time.
  2. Rive's advantage is architectural, not incremental. A binary read removes the entire parse-and-allocate phase. You cannot close a 6 ms vs 38 ms gap with a faster JSON parser.
  3. AVIF is fast per frame and fatal in aggregate. On the Galaxy A14, dav1d sustained 38–52 fps at 1080p. A 60 fps sequence therefore cannot decode in real time on that device — you must cap resolution, cap frame rate, or accept pre-buffering. AVIF is a short-loop format, not a scrubable one.

GPU Memory: The Line Item Nobody Budgets

File size is a terrible proxy for VRAM. Vector formats compress beautifully and then pay at rasterization; frame-based formats do the opposite.

code
VRAM_resident ≈ frames_in_flight × W × H × 4 bytes × (1 + alignment_overhead)

A 1080×1080 RGBA frame is 4.66 MB. Thirty resident frames is 140 MB — enough to trigger onTrimMemory(TRIM_MEMORY_RUNNING_LOW) on a 3 GB device mid-animation, which is exactly how you get a black flash during a purchase confirmation.

What actually happens per format:

  • Lottie/dotLottie: heap model (3–6× JSON) plus raster caches. On Android, setCacheSize and RenderMode.HARDWARE are the two dials — the hardware layer wins until your view exceeds max texture size (4096 px), at which point it silently falls back and costs more.
  • Rive: fixed renderer atlases and a single paint target per view. Memory is roughly constant per view, which means your risk is N concurrent views, not asset size.
  • SVGA: the decoded-frame bitmap cache grows linearly with frame count and resolution. This is the format that OOMs low-end Android.
  • AVIF: residency equals decoded frames held for buffering. Stream, never preload the whole sequence.

The Jank Budget: Allocate Milliseconds Like Money

code
frame_budget_ms   = 1000 / refresh_hz
animation_budget  = frame_budget_ms × allocation_pct

If you allocate 25% of the frame to motion and leave 75% for layout, input, and network callbacks:

RefreshFrame budgetAnimation budget (25%)
60 Hz16.67 ms4.17 ms
90 Hz11.11 ms2.78 ms
120 Hz8.33 ms2.08 ms

Measured main-thread cost per frame at 1080p: SVG-renderer Lottie 5–9 ms (blows every budget), Canvas-renderer Lottie 2.8–6 ms, Rive 0.4–1.1 ms, SVGA blit 1.5–3 ms plus periodic decode spikes.

Jank is a rate, not a latency. A 30 ms hitch every two seconds reads as visibly broken; a steady 18 ms frame reads as smooth. Instrument the rate.

Build a Runtime Adapter With Timeouts and Fallback

Never let a format decision leak into thirty call sites. Define one contract and let the coordinator own failure.

ts
export type RuntimeId = 'lottie' | 'dotlottie' | 'rive' | 'svga' | 'avif';

export interface FrameBudget {
  readonly refreshHz: 60 | 90 | 120;
  readonly sharedBudgetPct: number; // 0.25 => motion may use 25% of the frame
}

export interface PlayHandle {
  readonly id: string;
  play(): Promise<void>;
  stop(): void;
  dispose(): Promise<void>;
}

export interface AnimationRuntime {
  readonly id: RuntimeId;
  preload(src: string, signal?: AbortSignal): Promise<void>;
  play(src: string, mount: HTMLElement, budget: FrameBudget): Promise<PlayHandle>;
}

const DEFAULT_TIMEOUT_MS = 2_000;

async function withTimeout<T>(task: Promise<T>, ms: number, label: string): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(`[runtime] ${label} exceeded ${ms}ms`)), ms);
  });
  try {
    return await Promise.race([task, timeout]);
  } finally {
    if (timer) clearTimeout(timer);
  }
}

export class PlaybackCoordinator {
  constructor(
    private readonly runtimes: ReadonlyMap<RuntimeId, AnimationRuntime>,
    private readonly fallbackChain: readonly RuntimeId[],
  ) {}

  async play(src: string, mount: HTMLElement, budget: FrameBudget): Promise<PlayHandle> {
    const failures: Error[] = [];
    for (const id of this.fallbackChain) {
      const runtime = this.runtimes.get(id);
      if (!runtime) continue;
      try {
        await withTimeout(runtime.preload(src), DEFAULT_TIMEOUT_MS, `preload:${id}`);
        return await withTimeout(runtime.play(src, mount, budget), DEFAULT_TIMEOUT_MS, `play:${id}`);
      } catch (err) {
        failures.push(err instanceof Error ? err : new Error(String(err)));
        mount.replaceChildren(); // never leave a half-mounted canvas behind
      }
    }
    throw new AggregateError(failures, `All runtimes failed for ${src}`);
  }
}

The mount.replaceChildren() line matters more than it looks. A failed Rive mount that leaves a <canvas> at its default 300×150 will shift your layout on the fallback path — the classic slower and uglier failure mode.

Instrument the Jank Budget in Production

Relying on requestAnimationFrame deltas alone tells you the whole frame was slow, not whether your animation caused it. Pair rAF with the Long Animation Frames API for attribution.

ts
export interface JankReport {
  frames: number;
  jankyFrames: number;
  jankRate: number;
  p99FrameMs: number;
  budgetMs: number;
  longFrames: number;   // LoAF entries > budget
  blockingMs: number;   // total blocking duration
}

export function probeJank(refreshHz: number, motionShare = 0.25): Promise<JankReport> {
  const budgetMs = (1000 / refreshHz) * motionShare;
  const deltas: number[] = [];
  let longFrames = 0;
  let blockingMs = 0;

  let observer: PerformanceObserver | undefined;
  if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
    observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        longFrames += 1;
        blockingMs += (entry as PerformanceEntry & { blockingDuration?: number }).blockingDuration ?? 0;
      }
    });
    observer.observe({ type: 'long-animation-frame', buffered: true });
  }

  return new Promise<JankReport>((resolve) => {
    let rafId = 0;
    let last = performance.now();
    const started = last;

    const tick = (now: number) => {
      deltas.push(now - last);
      last = now;
      if (now - started < 3_000) {
        rafId = requestAnimationFrame(tick);
        return;
      }
      cancelAnimationFrame(rafId);
      observer?.disconnect();

      const sorted = [...deltas].sort((a, b) => a - b);
      const janky = deltas.filter((d) => d > budgetMs * 1.5).length;
      resolve({
        frames: deltas.length,
        jankyFrames: janky,
        jankRate: deltas.length ? janky / deltas.length : 0,
        p99FrameMs: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
        budgetMs,
        longFrames,
        blockingMs,
      });
    };

    rafId = requestAnimationFrame(tick);
  });
}

Emit this as a metric on every animation mount, tagged by runtime, device tier, and asset ID. Within a week you will know which two assets own 80% of your jank.

Gate Every Asset in CI

The cheapest performance work is the asset that never ships. Enforce size budgets at build time, not in a review comment.

js
// tools/asset-budget.mjs
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { readdir, stat, mkdir } from 'node:fs/promises';
import path from 'node:path';

const run = promisify(execFile);

const BUDGETS = new Map([
  ['.lottie', 80 * 1024],
  ['.riv', 50 * 1024],
  ['.svga', 200 * 1024],
  ['.avif', 160 * 1024],
]);

export async function enforceBudgets(dir) {
  const offenders = [];
  for (const entry of await readdir(dir, { recursive: true, withFileTypes: true })) {
    if (!entry.isFile()) continue;
    const ext = path.extname(entry.name).toLowerCase();
    const budget = BUDGETS.get(ext);
    if (!budget) continue;
    const filePath = path.join(entry.parentPath ?? entry.path, entry.name);
    const { size } = await stat(filePath);
    if (size > budget) {
      offenders.push(`${filePath}: ${(size / 1024).toFixed(1)}KB > ${(budget / 1024).toFixed(0)}KB`);
    }
  }
  if (offenders.length) {
    throw new Error(`Asset budget exceeded:\n${offenders.join('\n')}`);
  }
}

// AVIF sequence encode (requires libavif >= 1.0 for --fps)
export async function encodeAvifSequence(frameDir, outFile, fps = 30) {
  await mkdir(path.dirname(outFile), { recursive: true });
  await run('avifenc', [
    '--min', '0', '--max', '30',
    '--speed', '4',
    '--qcolor', '52', '--qalpha', '60',
    '--fps', String(fps),
    '-j', 'all',
    path.join(frameDir, '*.png'),
    outFile,
  ]);
}

Cost Per Million Plays: A Defensible Model

code
egress_usd_per_million = plays × cache_miss_ratio × bytes_per_play / 1e9 × cdn_price_per_gb

At $0.085/GB (typical first-tier CDN) with a 30% miss ratio (unique, non-repeat plays):

FormatBytes/playGB per 1M plays$/1M plays$/month at 500M
Lottie JSON420 KB126$10.71$5,355
dotLottie96 KB28.8$2.45$1,224
Rive38 KB11.4$0.97$485
SVGA180 KB54.0$4.59$2,295
Animated AVIF130 KB39.0$3.32$1,660

Now the honest part. Client energy is not on that invoice but is on your retention curve: a 900 ms CPU decode at roughly 1.8 W is ~0.45 mWh per play — invisible in dollars (~$0.07 per million plays at $0.15/kWh), brutal on a 3,000 mAh battery during a gaming session.

And the largest line item never appears in the CDN bill at all: maintenance. Keeping a legacy SVGA runtime patched, or maintaining a Lottie fork for a renderer bug, is a quarter of an engineer — roughly $5,000/month. That dwarfs every egress number in the table.

The cheapest line item is the one everyone argues about. Choose on engineering surface area first, bytes second.

The Decision Matrix

Product surfacePickWhy
Splash / onboarding sequencedotLottie or AVIFPassive playback, no state, aggressive caching
Empty states, micro-interactionsLottie or dotLottieAE pipeline, small scenes, wide compatibility
Gamified achievements, interactive charactersRiveState machine replaces app-side animation logic
Live gifting, high-concurrency overlaysRive, migrate off SVGAPer-view memory is constant; SVGA frame caches scale badly
Marketing landing pages, emailAnimated AVIFNo SDK, <img> native, no JS payload
Anything scrubable or input-drivenNever AVIFKeyframe seek gaps are a hard limit

Rules of thumb. If the interaction graph is complex enough to need a diagram, use Rive. If you have a mature After Effects pipeline you refuse to abandon, ship dotLottie and keep Lottie as the fallback. Treat SVGA as legacy debt with a migration ticket, not a greenfield choice. Reserve AVIF for passive, short, low-frame-count loops.

Pitfalls That Quietly Cost You a Quarter

  1. Benchmarking on desktop. The Galaxy A14 is your baseline; a MacBook is not.
  2. Confusing transfer size with decode cost. dotLottie cuts bytes; it does not cut parse.
  3. Preloading full frame sequences. Residency, not payload, triggers the OOM kill.
  4. Ignoring the p99. The slowest quarter of your fleet defines your store rating.
  5. Mounting a runtime inside a scroll container. Continuous re-layout turns a 1 ms renderer into a 12 ms one.
  6. No dispose path. Leaked renderers are the #1 cause of memory growth across a session.
  7. Measuring jank with rAF only. You will blame the animation for a slow analytics callback.
  8. No CI size gate. Assets grow 4% per sprint, forever.

Operational Checklist

  • Publish a per-format size budget and enforce it in CI.
  • Tag every play event with runtime, asset_id, device_tier, and cold_decode_ms.
  • Alert on p99 cold decode, not the mean.
  • Probe and emit jankRate for every mount; dashboard it by asset.
  • Assert a dispose() runs on every unmount; leak-test a 50-navigation session.
  • Cap resident decoded frames for any frame-based format.
  • Version assets immutably (max-age=31536000, immutable) to maximize client-side repeat-play savings.
  • Keep a documented fallback chain, and test the fallback path deliberately.

Sources & Technical References

2 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles