The hero image that waited in line
Two templates took over five seconds to show their main image on a slow phone. It wasn’t big; it was queued behind 23 others. Plus the caching bug that kept phones on year-old JavaScript.
The problem
Hero images took 5.4 s on phones, queued behind 23 other images.
The solution
Preload the hero, lazy-load the rest, and content-hash every asset.
Largest Contentful Paint (LCP) measures when the biggest thing on screen, usually the hero image, finishes appearing. On AHA’s /solutions and /experiences pages it was over five seconds on a throttled phone. The images were already compressed, so the problem wasn’t their size.
Diagnosis: a traffic jam, not a heavy truck
The network waterfall told the story. The hero started downloading at the same moment as 13 to 23 other images further down the page. All of them were marked to load eagerly, and the browser treated them as equally important. The hero didn’t lose because it was big. It lost because it was waiting its turn.
There was a second trap. A carousel built its slides by cloning existing image elements. The clone copied src before loading="lazy", so every cloned slide downloaded immediately, which quietly defeated lazy loading.
The fix
- Tell the browser what matters. The hero gets
fetchpriority="high"and a preload hint. Everything below it getsloading="lazy"and explicit width and height, so nothing shifts as it loads. - Fix the clone order. Set
loadingbeforesrcand swap the real image source in only when a slide scrolls into view. Image requests at load dropped from 22 to 2. - Fake the blur, don’t compute it. A CSS
filter: blur()over a full-screen hero costs real time on a weak phone, so the hero now uses a pre-blurred image. - Resize to the layout.
/contact-uswent from a 495 KB hero to 75 KB, with a 1024 px version insrcsetfor phones.
The other bug: phones running year-old JavaScript
Some returning visitors saw blank sliders. Their phones were running JavaScript from months earlier. The server told browsers to cache scripts and stylesheets as immutable for a year, but the filenames never changed. When we shipped a fix, those phones had no reason to fetch it.
The fix is the standard one, applied properly: every CSS and JS file gets an MD5 hash of its contents in its filename, at build time, after minification. Change one byte and you get a new filename, so caching forever becomes safe instead of dangerous.
// eleventy.after: hash, rename, rewrite references
const hash = createHash('md5').update(code).digest('hex').slice(0, 10);
const hashed = file.replace(/\.(js|css)$/, `.${hash}.$1`);
Results
Lighthouse lab runs, slow 4G with a 4× CPU throttle, before and after:
| Page | Before | After |
|---|---|---|
/solutions |
5.44 s | 1.11 s |
/experiences |
5.15 s | 1.18 s |
/contact-us |
2.95 s | 1.48 s |
/ (home) |
2.68 s | 1.44 s |
One more lesson
The lab numbers passed, but real-user data stayed slower than they suggested. The giveaway was that the first paint arrived almost as late as the main image, and the server took about 1.2 s to respond. Past a point, the bottleneck is the hosting, not the page. That fix is documented and waiting on infrastructure access.