LCP Regression After CSS Container Queries Migration

Migrated to CSS container queries and watched LCP jump 800ms in field data. Here is why the hero image stops being a priority paint and how to fix it.
PerformanceCore Web VitalsCSS
June 1, 20265 min read984 words

The Problem

Migrated a client's marketing site from media-query breakpoints to CSS container queries. Cleaner code, layouts that respond to their parent instead of the viewport, the lot. Shipped on a Tuesday. By Friday morning the Core Web Vitals report in Search Console had flipped the home page from "Good" to "Needs improvement" on mobile, and the Vercel Speed Insights field-data graph for LCP showed a clear step up from 1.8s to 2.6s the day of the deploy.

PageSpeed Insights lab numbers were fine — LCP 1.4s on the moto-g-power profile. The synthetic test on WebPageTest looked identical to the pre-migration build. The regression was field-data only and it was big enough to push the page's CWV status across every URL group on the property.

Why It Happens

When the LCP element is an image that lives inside a container-query container, the browser cannot pick its src candidate from srcset until layout has resolved the container's size. The container size depends on its parent. The parent might depend on the viewport, or it might depend on its own container further up the tree. Either way, the resolution work has to happen before the browser knows which candidate from srcset is the right one to download.

Under media queries, matching happens against the viewport, which the browser knows from the very first byte. The preload scanner sees the <img> tag and starts the download immediately. Under container queries, the matching has to wait for at least the first style and layout pass of the ancestor chain. That pushes the discovery of the right source from "as soon as the preload scanner sees the tag" out to "after the first layout completes", and on slower devices that gap is the entire LCP regression.

This is not a browser bug. It is a documented consequence of how the sizes attribute interacts with container queries. The MDN page on container queries calls out the layout-dependency cost in passing, but the LCP implication is not on most teams' radar when they plan the migration.

The Fix

For the LCP image specifically, do not use a container-relative sizes value. Use a viewport-relative one, or hard-code the source the LCP image will actually render at:

// Wrong: sizes depends on the container, which depends on layout.
<Image
  src="/hero.jpg"
  alt="Product hero"
  width={1200}
  height={600}
  sizes="(min-width: 64em) 100cqi, 100vw"
  priority
/>

// Right: sizes is viewport-relative. The preload scanner can pick
// a source immediately. The rest of the page can still use container queries.
<Image
  src="/hero.jpg"
  alt="Product hero"
  width={1200}
  height={600}
  sizes="(min-width: 1024px) 1200px, 100vw"
  priority
/>

For non-LCP images in container-query containers, the difference does not matter. Those were going to wait for layout anyway. The fix is targeted at the one image per page that the browser will report as the LCP element. Identify it with a quick PerformanceObserver:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType !== 'largest-contentful-paint') continue
    console.log('LCP element:', entry.element, 'at', entry.startTime, 'ms')
  }
}).observe({ type: 'largest-contentful-paint', buffered: true })

Drop that into a one-off bookmarklet against the live site on a throttled mobile profile and you will see exactly which element the browser is reporting. If it is the hero image, swap its sizes value first and re-measure.

A second thing worth checking. If you migrated a hero from a viewport-relative vw sizes to a container-relative cqi, the new srcset candidate you wanted to win may not be picked at all because the container hits its query at a different breakpoint than the viewport did. Use Lighthouse's LCP-element audit to confirm which source actually downloads after the change:

npx lighthouse https://yourapp.com/ \
  --only-audits=largest-contentful-paint-element \
  --output=json --output-path=lcp.json \
  --preset=mobile

The audit's details object names the URL of the LCP resource and its render timing. If the URL is the 1600px source on a 320px-wide phone, your sizes is wrong even after the migration fix.

The last piece is preloading the LCP image. Container-query containers eat the value of the priority hint on most image components because the preload scanner cannot pre-resolve which candidate to fetch. Preload explicitly with react-dom:

import { preload } from 'react-dom'

export default function Page() {
  preload('/hero.jpg', {
    as: 'image',
    imageSrcSet: '/hero-800.jpg 800w, /hero-1200.jpg 1200w, /hero-1600.jpg 1600w',
    imageSizes: '(min-width: 1024px) 1200px, 100vw',
  })
  return <HomePage />
}

That preload fires before any layout work and tells the browser which candidate to download. Combined with viewport-relative sizes on the actual <img> tag, the LCP image starts coming off the network from the very first byte and the field-data regression goes away. On the client I shipped this for, field LCP at p75 was back to 1.85s within three days, which is the rollover window for the Chrome User Experience Report.

The Lesson

Container queries are a layout-dependent feature. The browser has to wait for layout before it can pick a source from a container-relative sizes value. On the LCP image, that wait is the regression. Keep container queries everywhere else, give the LCP image a viewport-relative sizes, and preload the resolved source. Field-data LCP returns to where it was before the migration.

If your Core Web Vitals regressed after a CSS refactor, that is a project I get paid to diagnose. See my services. For a related preload gotcha, read LCP preload wrong image fix.

CWV regressed after a refactor? Get it shipped.

Back to blogStart a project