Mobile LCP: cutting hero image cost on a CMS-driven Next.js site
    Back to Blog
    Performance

    Mobile LCP: cutting hero image cost on a CMS-driven Next.js site

    February 15, 20265 min read
    PerformanceCore Web VitalsNext.jsImages

    The setup



    The site is a Next.js + GraphQL build for a high-traffic UK consumer brand on a headless CMS. Landing pages share a common pattern: a hero banner module followed by content carousels, feature tables, and module-driven sections. Images are served through a CDN that supports query-string transformations (?width=…&quality=…).

    Mobile LCP on the landing pages was visibly poor in field data — the hero image consistently missed the "good" Core Web Vitals threshold for a meaningful share of traffic.

    What was actually slow



    The page was rendered server-side, so the HTML arrived quickly. The bottleneck was the hero image:

  1. **Discovery was late.** The hero image lived inside a CMS-driven module rendered after a script-heavy header and a navigation block. The browser preload scanner found it, but only after a stack of higher-priority requests.

  2. **Quality was over-spec for mobile.** Editors pushed CMS images at desktop resolution (1920w), and the renderer didn't downshift quality on small viewports. A 280KB hero on a 4G connection with high RTT eats most of the LCP budget by itself.

  3. **srcset was generous but unfocused.** The image had srcset entries from 320w up to 2400w, but sizes was a single 100vw everywhere — meaning mobile devices sometimes still requested oversized variants.


  4. What changed



    Three small changes, in priority order:

    1. **fetchpriority="high" on the LCP candidate.** Only the first hero image on the page got it — every other module fell back to default. Done at the renderer level so editors couldn't accidentally promote a non-LCP image.
    2. **Quality tuned per breakpoint.** The CDN URL builder now emits quality=70 for mobile widths and quality=82 for desktop. Visually it's hard to tell on a phone; the byte savings on the hero alone were significant.
    3. **Tighter sizes.** Replaced the blanket 100vw with a media-query-driven sizes attribute that reflected the actual layout ((max-width: 768px) 100vw, 720px for the hero column). The browser stopped requesting oversized variants on mid-size phones.

    I avoided the obvious "preload" approach. A from the document head would force the browser to fetch the hero before it knew which variant was needed, which on a CMS-driven layout meant either preloading the wrong image (when the editor swapped the module) or duplicating the request.

    Trade-offs worth flagging



  5. **Editor surface area.** Tuning quality server-side meant editors lost the ability to bump quality back up for branded campaign images. We added a CMS toggle for that, gated to a small allow-list.

  6. **fetchpriority is a hint, not a guarantee.** On low-end Android devices behind a memory-constrained Chrome, the browser still occasionally deprioritized the image when many ads/3rd-party scripts were in flight. The right long-term fix is reducing 3rd-party script weight, not chasing the priority hint harder.

  7. **Cumulative Layout Shift.** Tighter sizes plus aspect-ratio meant we had to enforce CMS aspect ratios — otherwise an editor uploading a 1:1 image into a 16:9 slot caused CLS regressions. We added validation at the CMS level.


  8. What actually moved



    LCP on model pages improved on mobile field data within the next two collection windows. The single biggest contribution was the quality change — image transfer size dropped, and that dominates LCP on slow connections. fetchpriority helped at the margin. The sizes fix mostly removed the worst-case spikes on mid-range phones.

    I'd rank the changes the same way next time: **bytes first, priority second, layout fixes third.**
    Share this article