Vercel Static Site Loaded in 6 Seconds — Images Were the Whole Problem
Performance audit on image-heavy tutorial blog. Lazy loading, responsive srcset, and moving off unoptimized PNGs cut LCP 74%.
Lighthouse mobile: LCP 6.2s, Performance 54 Hosting: Vercel (not the bottleneck — assets were)
Waterfall autopsy
hero.png 2.8 MB 4.1s (no dimensions, no lazy)
step-1.png 1.9 MB blocked by hero
step-2.png 1.7 MB
Six PNG screenshots above fold on every post.
Fix 1: width/height on every img
<img src="/images/hero.webp" width="1200" height="630" alt="..." fetchpriority="high">
Stops CLS. Browser reserves space before decode.
Fix 2: lazy below fold
<img src="/images/step-1.webp" width="800" height="450" loading="lazy" decoding="async" alt="...">
Fix 3: responsive srcset (static build)
Hugo example:
<img
srcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w"
sizes="(max-width: 600px) 100vw, 800px"
src="/images/hero-800.webp"
width="800" height="420"
alt="...">
Mobile downloads 400w (~45KB) not 1200w (~280KB).
Fix 4: don't use Vercel Image Optimization for static export
Requires Next.js or custom loader. For Hugo/Astro, optimize at build time, not request time.
Vercel-specific: enable compression
Brotli/gzip automatic for text. Images must be compressed before deploy.
Pre-deploy image audit script
// scripts/audit-images.mjs
import fs from 'node:fs';
import path from 'node:path';
function walk(dir) {
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, f.name);
if (f.isDirectory()) walk(p);
else if (/\.(png|jpe?g)$/i.test(f.name)) {
const kb = fs.statSync(p).size / 1024;
if (kb > 200) console.log(kb.toFixed(0) + ' KB', p);
}
}
}
walk('public/images');
Fail CI if any image > 200KB — catches regressions before they hit Vercel.
After
LCP: 6.2s → 1.6s
Total page weight: 9.1 MB → 420 KB
Performance score: 54 → 91
Vercel edge was fast all along — payload wasn't.