My portfolio repo had 146 MB of images. Two of them were 22% of the total. One was a PNG 11,811 pixels wide.

I only found out because I finally measured instead of guessing. This is what I learned, with the actual numbers — and the rules I now apply to every image I publish.

First: the repo is not the page

The instinct is to panic about the total. Don’t. What a visitor downloads is what a single page references:

  • Home: 1.27 MB across 3 images
  • Work index: 2.67 MB across 7 images
  • A case study page: 0.02 MB in one image

Nobody was ever downloading 146 MB. But the repo size was still a symptom of the real problem.

The real problem: originals that were never exported

The images weren’t “heavy”. They were raw exports:

  • arbor_cnp_2.png — 23.4 MB, 11,811 × 8,268 px. Nothing on the site is ever displayed wider than ~1,500 px.
  • arbor_gif.gif — 13.3 MB (600 × 450, 13 seconds)
  • arbor_gif-2.gif — 18.3 MB (600 × 600, 5 seconds)

Two GIFs, 31.6 MB. That’s the whole problem in two files.

Rule 1 — pick the width from the layout, not from the file

The right width is display width × 2 (retina), and it changes with the role of the image. In my site:

Role Displayed at Export at
Grid card (3-column, 1140px container) ~370 px 800 px
Image inside a case (740–760px column) ≤760 px 1500 px
Full-bleed / hero viewport 2000 px

“Export everything at 1800px” is what I did first, and it’s wrong: it wastes weight on cards and falls short if something opens full-screen. If an image appears at several sizes, ship a srcset and let the browser choose.

Rule 2 — quality depends on what’s in the image

  • Photos: WebP at q80 — invisible difference.
  • UI screenshots with text: q90. At q80 the type goes mushy. This is the single most common mistake.

Rule 3 — a GIF is never the answer

GIF is a 1987 format: 256 colours per frame, no inter-frame compression. Same animation as H.264 video:

  • 13.33 MB → 0.353 MB (−97%)
  • 18.29 MB → 0.298 MB (−98%)

Same content, 40× smaller, better quality (real colour, no dithering), plus a poster so there’s no blank first frame, muted loop playsinline so it behaves like a GIF, prefers-reduced-motion support, and hardware decoding instead of pinning a CPU core.

Rule 4 — check the source before you blame the conversion

After converting, I compared the original with the WebP at 1400px and q90: the aliasing and the halo around the text were already in the original. So if an image looks bad, re-export it from the source file — don’t compress harder and hope.

The script

One shell script, one line per image with its role, and the original never gets touched:

images/work/arbor/arbor_cnp_2.png|figure
images/work/arbor.png|card
images/work/gfs/ss-no-filters.png|ui

Result on 95 files: 69.21 MB → 7.96 MB (−88%). No visible loss, because the widths and qualities now come from the layout.

The bug that cost me an hour

ffmpeg reads stdin. Inside a while read line loop over my image list, it swallows the rest of the list — so the file on the next line silently gets skipped. My script printed a mangled path and I spent twenty minutes convinced two files didn’t exist.

The fix is one flag: -nostdin.


None of this needs a framework with an image CDN. It needs knowing your own layout, four rules, and a script you run once. Which is the same reason I still build this site by hand: when you own the stack, the fix is a file in your repo, not a plan upgrade.

(If you’re building your portfolio from scratch, start with How to build your portfolio.)