Cutting a 46 MB hero animation down to 4 MB
My portfolio's scroll-driven hero looked great and froze the page for the first few seconds. Here's what was actually wrong and how I fixed it.
My portfolio opens with a scroll-scrubbed image sequence — 120 frames of a portrait that animate as you scroll. It looked exactly how I wanted. It also froze the page for several seconds on first load, which is the one thing a portfolio cannot do.
The frames were PNGs: 120 files, 46 MB total. Every one was requested at once, and nothing on the page rendered until all of them had downloaded.
Why it was so bad
Three separate mistakes stacked on top of each other:
- The format. PNG is lossless, which is wonderful for screenshots and terrible for 120 frames of a photograph.
- The request pattern. A
forloop fired all 120 requests immediately, so the browser had no chance to prioritise anything. - The gate. The loading screen waited for every frame before revealing the page — including frame 119, which you only reach after scrolling for a while.
Converting the frames
WebP at two sizes — one for phones, one for desktop:
await sharp(src)
.resize({ width: 1440 })
.webp({ quality: 80, effort: 6 })
.toBuffer();
Result: 46 MB → 2.96 MB for desktop, 1.11 MB for mobile. About 91% smaller, and at normal viewing size I genuinely cannot tell the difference.
Picking the tier once at load keeps phones off the desktop assets:
const tier =
window.innerWidth * Math.min(devicePixelRatio, 2) <= 900 ? 768 : 1440;
Loading in two phases
The real unlock wasn't the file size — it was what you wait for. Now only three frames spread across the timeline gate the preloader:
const GATE_FRAMES = [0, 40, 80];
That's about 300 KB. The other 117 frames stream in behind the lifted preloader through a small worker pool, in ascending order, so the frames you reach first arrive first. If you scroll faster than they load, the canvas draws the nearest frame it already has instead of going blank.
Decoding moved off the main thread too:
const res = await fetch(url, { signal });
return createImageBitmap(await res.blob());
createImageBitmap decodes on a worker thread. With new Image() every decode
competes with rendering, which is a big part of why scrolling stuttered.
The measurement that mattered
Throttled to 4 Mbps, the preloader used to sit there for roughly 92 seconds. After the change: 3.3 seconds.
Interestingly, Lighthouse barely noticed either version — it finishes measuring before the old 46 MB even started arriving, so it happily scored a page that was about to freeze. Lab tools measure what they can see; they don't always measure what your visitors feel.
Takeaways
- PNG sequences are almost always the wrong format — WebP cost me ~90% for no visible loss.
- Split loading into what blocks the page and what can stream in after. It's usually a much smaller gate than you assume.
- Decode with
createImageBitmapwhen you're pushing frames to a canvas. - Always give a scrubbing canvas a nearest-frame fallback, or fast scrolling shows an empty box.
- Don't let a green lab score convince you the page is fast. Throttle it and watch it load.
If you're building something similar and hit a wall, tell me where — I've probably hit the same one.