All posts
3 min read

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.

PerformanceNext.jsWeb

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:

  1. The format. PNG is lossless, which is wonderful for screenshots and terrible for 120 frames of a photograph.
  2. The request pattern. A for loop fired all 120 requests immediately, so the browser had no chance to prioritise anything.
  3. 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 createImageBitmap when 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.

ShareXLinkedInWhatsApp

Written by

Zaid Sayyed

Software Engineer · AI & Cross-Platform Apps. Currently open to roles and freelance work.