# 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.

By Zaid Sayyed · Published 2026-07-30 · 3 min read · https://zaidsayyed.in/blog/cutting-a-46mb-hero-to-4mb

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:

```js
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:

```ts
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:

```ts
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:

```ts
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.

---
Source: https://zaidsayyed.in/blog/cutting-a-46mb-hero-to-4mb
