Back to blog
Article

Ship a 2MB hero to a 3G phone and watch LCP collapse

Ship a 2MB hero to a 3G phone and watch LCP collapse
S

StriveBit

4 min readWeb Applications

Ship a 2MB hero to a 3G phone and watch LCP collapse

A client's marketing site was built with Next.js and next/image. On office wifi it loaded in under a second. On a Pixel 4a throttled to 3G — which is how most of their users actually visit — the hero image took 5.8 seconds to paint. The page couldn't ship.

The root cause was a 2.1MB JPEG that next/image was happily generating srcsets for, but the browser kept choosing the largest variant because the `sizes` attribute was wrong.

next/image does a lot of work for you, but it does not read your layout. You have to tell it how wide the image will actually render, and most teams get this wrong.

The common setup looks like this:

<Image
  src="/hero.jpg"
  alt="Product photo"
  width={1920}
  height={1080}
  priority
  sizes="(max-width: 768px) 100vw, 50vw"
/>

The `sizes` attribute tells the browser what CSS width the image will occupy at each breakpoint. The browser then picks the smallest srcset entry that fills that width at the current device pixel ratio. If you omit `sizes`, next/image assumes `100vw` — which on a 400px-wide phone with a 2x screen means the browser wants an 800px-wide image. That's usually fine.

The problem is when `sizes` is set to something larger than the actual rendered width. A hero that's full-width on mobile but constrained to a 600px container on desktop should not have `sizes="100vw"`. If it does, the browser on a 1440px desktop requests the 1920px variant for a 600px slot. On 3G, that request blocks LCP.

The fix is to measure the actual rendered width and set `sizes` to match. Use DevTools' element inspector — the rendered width is right there. For a hero that spans full width on mobile and sits in a 600px container above 1024px, `sizes` should be `"(max-width: 1023px) 100vw, 600px"`. Not `50vw`, not `100vw`, not the width of the source file.

`priority` is the other lever. It adds `fetchpriority="high"` to the img tag and tells the browser to start the request immediately. This is correct for above-the-fold hero images and wrong for everything else. We've seen pages where every image in the first viewport has `priority`. The browser can't prioritize everything, so it prioritizes nothing, and LCP slips by 800ms.

The quieter failure is the source image itself. next/image will serve a resized variant, but it never recompresses beyond what the CDN does. If you upload a 2MB JPEG, the 1920px variant might still be 400KB. On 3G that's a 2-second download. Before you ship, check the actual file size of the variant the phone requests. In Chrome DevTools, throttle to Slow 3G, reload, and look at the Network tab. The hero image request should be under 80KB for a mobile breakpoint.

If it's not, the source image is too large or not compressed enough. Run it through sharp or squoosh before uploading. A 1920px hero at quality 72 in AVIF is typically 60-90KB. The same image as a 2MB JPEG from a stock photo site will cost you 3 seconds on 3G.

next/image also generates AVIF and WebP automatically when the source is supported. But it only does this if the original file is within the `deviceSizes` and `imageSizes` configured in next.config.js. If your source is 4000px wide and you haven't bumped `deviceSizes`, the variants may not cover the range you need. Check the output in `.next/cache/images` after a build — that's where the generated files live.

The test is always the same: throttle to 3G, reload on a mid-range Android, and measure LCP in Lighthouse. If it's over 2.5 seconds, the hero image is the first place to look. Not JavaScript bundle size, not font loading — the image.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch