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.

27 Oct 2026 2 min readCase study · Website & search programme

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.

−80%LCP on /solutions
5.4 s → 1.1 sslow 4G, 4× CPU
22 → 2image requests at load

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 gets loading="lazy" and explicit width and height, so nothing shifts as it loads.
  • Fix the clone order. Set loading before src and 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-us went from a 495 KB hero to 75 KB, with a 1024 px version in srcset for 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.

More problems, more solutions.

All posts

Non-brand clicks are the SEO number that matters

+183% non-brand clicks

A path-traced showroom in a TV browser

22 renders, path-traced offline in about 2 hours