#Performance#Web#Images#Client work

286 MB down to 39 MB: fixing a slow photo gallery

A client's gallery page was crawling. The cause wasn't the code — it was 286 MB of untouched phone photos, one of them loaded as a thumbnail. Here's the twenty-line fix.

The gallery page took an age to load. The odd part: the code was fine. The images weren't.

I look after Split Lip Adventures — the site for firefighter, world-record holder and author Scott Butler. It's a static Next.js build, so there's no server doing anything clever at request time; the browser just downloads files. Which makes a slow page suspicious. Static sites are supposed to be fast by default.

The gallery — a globe you spin, with a photo grid underneath — was the one page that dragged. Before touching any code, I measured the thing everyone forgets to measure.

The images were never compressed

Two hundred images. 286 MB between them. They'd been carried over from the old site exactly as they came off a phone — 4000 pixels wide, several megabytes each, the biggest weighing in at 8.7 MB. No resizing, no compression, nothing. 88 of them were over a megabyte apiece.

On its own, big files on a photo page might be forgivable. But there was a second problem sitting on top, and it's the one that actually hurt.

A 4000-pixel photo behind a 40-pixel pin

Each location on the globe has a little photo pin — maybe forty pixels across. Each tile in the grid is a small square. Both were pointing at the full-size original. So to draw a thumbnail the size of a postage stamp, the browser downloaded a five-megabyte, 4000-pixel photograph, then threw 99% of it away scaling it down. Do that for a screenful of tiles and the page grinds while megabytes stream in for images no one sees at full size.

That's the real lesson here: the bottleneck wasn't the total weight so much as serving the wrong size for the job.

The fix: WebP, two sizes, one script

Nothing exotic. Three decisions, all boring on purpose.

WebP instead of JPEG. It's about 30% smaller at the same visible quality and every browser has supported it for years. Sharp — the image library that ships with Next already — encodes it, so no new dependency.

Two sizes of each image. A 1600-pixel version for when a photo is actually viewed large, and a 640-pixel thumbnail for grids and pins. The page now loads the small one and only fetches the big one in the lightbox, when you click to enlarge.

Keep the originals safe. The full-resolution files got moved out of the deployed folder into an untracked originals directory. The script rebuilds everything from those, so the source of truth is untouched and I can regenerate at any quality later.

The whole thing is one short, idempotent script — run it as often as you like, it skips images it's already done and only processes new ones:

await sharp(input)
  .rotate() // bake in EXIF orientation before it's stripped
  .resize({ width: size, height: size, fit: "inside", withoutEnlargement: true })
  .webp({ quality })
  .toFile(dest);

That .rotate() line matters more than it looks. Phone photos store their orientation as metadata rather than in the pixels; strip the metadata during conversion and a chunk of them flip sideways. Calling rotate() first bakes the correct orientation into the pixels so they stay upright.

Then a find-and-replace pointed every reference in the site from .jpg to .webp, the grids and pins were switched to the thumbnail path, and the lightbox and hero images kept the 1600-pixel version.

The outcome

286 MB of images became 39 MB — 31 MB of full-size WebP plus under 7 MB of thumbnails. The whole exported site dropped from around 300 MB to 54 MB. But the number that matters is the one you feel: the gallery grid went from streaming multi-megabyte originals per tile to roughly 30 KB each. It just appears now.

No framework, no image CDN, no clever build pipeline. About twenty lines of Sharp and a bit of find-and-replace. The satisfying fixes are usually the ones where you delete 250 MB and the site looks identical — just faster.