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