Animated WebP or MP4 for short looping animations? Most write-ups end with "it
depends, measure it", so I measured it on the 442 exercise clips I ship, and
the useful answer turned out not to be a winner but an order of operations.
The asymmetry decides it:
- From a transparent clip you can produce anything — composite any background, export MP4, resize per platform, re-theme next quarter. One build command, at the bottom of this post.
- From an opaque clip you cannot get the background back. Resize and re-encode still work, but the panel colour is in every pixel: no new theme, no round container, no clean transparency without re-rendering.
So the transparent WebP is the master, and MP4 is a delivery format you
generate from it. Here is what each costs.
The measured comparison
Six clips spanning the size range of the catalogue. Every variant re-encoded
from the same source with the same settings (quality 90, method 4, exact
alpha); MP4 is H.264 crf 23, medium preset, composited on #111827:
| Clip | WebP 960, transparent | Same, composited (opaque) | MP4 crf 23 | MP4 crf 28 |
|---|---|---|---|---|
| Single Leg Glute Bridge | 1232 KB | 474 KB | 71 KB | 43 KB |
| Mountain Climbers | 1316 KB | 522 KB | 100 KB | 61 KB |
| Side-Lying Lateral Raise | 2186 KB | 1049 KB | 94 KB | 51 KB |
| Incline Dumbbell Curl | 3387 KB | 1381 KB | 128 KB | 65 KB |
| One Arm Kettlebell Push Press | 4093 KB | 1435 KB | 152 KB | 86 KB |
| Battle Ropes | 5022 KB | 1841 KB | 419 KB | 214 KB |
Read it left to right: one master and two things you can make from it, not
three products to choose between.
Fixing the background saves ~2.5x. Same format, same settings, alpha gone —
the median file drops to 39%. That is what the alpha channel costs.
Encoding as video saves another ~8x, because a video codec has interframe
prediction and an animated image format does not. End to end: 3387 KB → 128 KB
for one clip, from a file you already have.
The master is the biggest file in the table. That is what a master is for.
What transparency buys
Not aesthetics — options you would otherwise have to buy again:
- Themes as a feature. If users pick or purchase a theme, every asset has to look right on all of them. A transparent figure composites onto whatever the theme sets; an opaque one needs an export per theme, from whoever owns the source artwork.
- White-label. The same catalogue in a client's palette, without re-cutting a file.
- Dark mode, which is the same problem and catches most teams after launch.
- Any container — round avatars, rounded cards, overlays on a photo.
- MP4 on demand. Pick the background, run the recipe below, ship video where it helps, regenerate it at the next redesign.
Same movement, both ways, on two surfaces:
The video is a fraction of the bytes and completely correct — on the panel it
was exported for. Move it to a light card and it is simply wrong. Fine, as long
as it is a derivative you can regenerate rather than the only copy you own.
What MP4 costs you back
A twelve-card grid, three ways, every tile at 240 CSS px, Chrome on a desktop
over localhost:
| Grid of 12 | Bytes | Load | Sustained frame rate |
|---|---|---|---|
| 480px stills | 0.26 MB | 54 ms | 56 fps |
| 960px animated WebP | 37.92 MB | 83 ms | 56 fps |
| MP4, background baked in | 1.97 MB | 296 ms | 28 fps |
The MP4 grid moved 20x fewer bytes and ran at half the frame rate: twelve
<video> elements decoding and compositing at once is real work. Pausing
eleven put it back to 56 fps; resuming them dropped it to 28 again.
The WebP row needs a warning label
That 56 fps is the page's frame rate from requestAnimationFrame. An
animated image decodes and rasters outside the page's animation loop, so a clip
can drop half its frames while the page reports a calm 56 — and no browser API
exposes an image's real playback rate. Drawing it to a canvas returns a frozen
frame, not the current one.
The decode work can be measured, with the browser's own ImageDecoder:
const buf = await (await fetch(url)).arrayBuffer();
const dec = new ImageDecoder({ data: buf, type: 'image/webp' });
await dec.tracks.ready;
await dec.completed;
const frames = dec.tracks.selectedTrack.frameCount;
const t0 = performance.now();
for (let i = 0; i < frames; i++) {
const { image } = await dec.decode({ frameIndex: i });
image.close();
}
console.log((performance.now() - t0) / frames, 'ms per frame');
Four clips, every frame, best of three passes:
| Clip | Frames | 960 px | 480 px |
|---|---|---|---|
| Mountain Climbers | 34 | 4.48 ms/frame | 0.82 ms/frame |
| Battle Ropes | 99 | 4.61 ms/frame | 0.95 ms/frame |
| Incline Dumbbell Curl | 60 | 5.16 ms/frame | 1.09 ms/frame |
| Side-Lying Lateral Raise | 41 | 5.41 ms/frame | 1.07 ms/frame |
At the clips' own 20 fps that is 90–108 ms of decode per second of playback
for a 960px clip — a tenth of a core, per clip, on a fast desktop. At 480px,
16–22 ms.
Twelve of them need ~1.1 s of decode per second of wall clock. Nothing plays
correctly under that. The page stayed at 56 fps because the animations, not the
page, absorbed the shortfall — where the metric could not see it.
So:
- MP4 is far cheaper to download, rides the video pipeline, and gets expensive in bulk — visibly, in the page's frame rate.
- Animated WebP is heavier to download, costs ~10% of a core per master clip to play, and degrades invisibly.
- A still beats both wherever nobody is studying the motion.
Whatever you pick, play one clip at a time. That single rule outperforms the
format choice.
Converting: the recipe that actually works
This is where the standard advice fails. The obvious command —
# does NOT work on these clips
ffmpeg -i animation.webp ... output.mp4
— fails on FFmpeg 8.1 with image data not found. FFmpeg only learned to
decode animated WebP in 7.1, and its decoder still chokes on clips whose frames
are stored as blended sub-rectangles rather than full canvases, which is what a
size-optimised encoder produces. Every clip I tried failed the same way.
Extract the frames with libwebp's anim_dump first — it composites each frame
onto the full canvas — then encode the PNG sequence:
CLIP=animation.webp
# 1. frames out (libwebp ships anim_dump alongside cwebp)
mkdir -p frames
anim_dump -folder frames "$CLIP"
# 2. the clip's own rate rather than a guess: frames / total duration
FPS=$(webpinfo -summary "$CLIP" \
| awk '/Duration/ {n++; ms += $2} END {printf "%.0f", n / (ms / 1000)}')
# 3. composite on the panel colour and encode
ffmpeg -y \
-framerate "$FPS" -i "frames/dump_%04d.png" \
-f lavfi -i "color=c=#111827:s=960x960:r=$FPS" \
-filter_complex "[1:v][0:v]overlay=shortest=1:format=auto,format=yuv420p[v]" \
-map "[v]" -an \
-c:v libx264 -crf 23 -preset medium -movflags +faststart \
output.mp4
Three things that bite:
-
Frame rate. The colour source dictates the output rate through the
overlay, so both
-framerateandr=must match the clip. Assume 25 and you ship duplicated frames. - Alpha edges. Check the result on a light and a dark background. Incorrect compositing shows up as a pale fringe around thin objects.
- crf. 23 is a safe default; 28 roughly halved the files again with no obvious loss at card size — flat illustration compresses well.
Choosing between them
| Requirement | Choice |
|---|---|
| Asset must sit on light, dark, and brand surfaces | Animated WebP |
| One fixed background across the product | MP4 derivative |
Display with a plain <img>
|
Animated WebP |
| Play, pause, seek, visibility control | MP4 |
| Several clips visible at once | Neither — stills, one active clip |
| Bandwidth-critical, single active clip | MP4 |
| A master you can re-theme without a transcode step | Animated WebP |
Method
Six clips from the current build, 1.3–5.1 MB, covering the size range of 442
shipped animations. WebP variants re-encoded at quality 90, method 4,
exact=True, disposal 2. MP4 encoded with FFmpeg 8.1, libx264, crf 23/28,
medium preset. Grid test: Chrome on a desktop, twelve cards at 240 CSS px in
a four-column grid, served over localhost, frame rate sampled over three
seconds via requestAnimationFrame. Localhost removes the network, which is
why the byte column deserves more weight than the load column. Decode cost:
Chrome's ImageDecoder, file already in memory, so it isolates decode from
network and layout.
Reading an animated image's actual on-screen frame rate is not possible from
page JavaScript. Where this post says an animation stutters, that is an
observation; the decode budget is the measurement that explains it.
Disclosure
The clips are from RepDB, an exercise dataset I build and sell. It ships the thing this post argues for: looping exercise animations at 960px on transparent backgrounds, plus stills in two styles, as files you own. That is why both columns of the table came from one source — I had the master, so the opaque and MP4 variants were an export rather than a re-render. Every other illustrated exercise set I looked at ships opaque frames — GIF or photography — with the background painted in, which is the one property you cannot get back.
The original post has the same measurements with the figures running live, and the free preview bundle is there if you would rather benchmark the actual files than take my table for it.

Top comments (0)