Writing · June 1, 2026 · 9 min read
The cookie banner that became my LCP: a 5.7 second lesson in Core Web Vitals
Core Web Vitals · LCP · performance · Next.js
TL;DR: A static, CDN-cached page on my side project scored a 5.7 second LCP on mobile while FCP sat at a healthy 1.2 seconds. The Largest Contentful Paint element turned out to be the analytics consent banner, a client-only component I had deliberately deferred until after hydration. That deferral was the bug: LCP tracks the largest element in the viewport whenever it paints, so pushing a large element later only pushes LCP later. I had five candidate fixes on the table, from shrinking the banner to gating it behind a server-side cookie. The winner was the counter-intuitive one: render the banner earlier, server-side into the static HTML, with a tiny inline script that hides it before paint for visitors who already made their choice. First-visit FCP to LCP gap went from 4.5 seconds to zero and the page stayed fully cacheable.
The problem: a static page with a five second LCP
The page in question was about as favorable a case as web performance gets: statically generated, served from a CDN, no client-side data fetching for the main content. Real users on fast connections described it as instant. Yet PageSpeed Insights kept reporting an LCP of 5.7 seconds on the throttled mobile run, while FCP was around 1.2 seconds.
That combination is the tell. On a static page the content paints at first paint, so FCP and LCP should land together. A gap of four or more seconds between them means the metric is not measuring your content at all. Something large is painting late, and the LCP algorithm is waiting for it.
How the LCP element is actually chosen
Largest Contentful Paint is not "when the hero rendered". The browser keeps a running candidate: every time a contentful element (an image, or a block containing text) paints in the viewport and is larger than the current candidate, the candidate is upgraded and the LCP timestamp moves to that element's render time. This continues until the first user interaction, at which point the candidate is frozen.
Two properties of my consent banner made it the perfect LCP hijacker:
- It was big. A bottom-fixed bar spanning the full viewport width with several lines of legal copy is a large block of text, easily larger than a heading or a paragraph of real content on a phone screen.
- It painted last. The component mounted from a
useEffectafter hydration, and on the throttled test profile hydration is gated behind downloading and executing roughly 300 KiB of JavaScript. The banner appeared seconds after the content, was immediately the largest thing on screen, and dragged the LCP timestamp with it.
So the metric was technically correct. The largest contentful element in the viewport really did paint at 5.7 seconds. It just happened to be a cookie bar nobody thinks of as content. And my first instinct, deferring it even harder with a dynamic import or an idle callback, points the wrong way entirely: LCP does not care when an element starts loading, only when the largest one finishes painting. Deferring a large element cannot make it stop counting, it can only make it paint later.
Finding the element instead of guessing
Before weighing fixes I wanted proof of which element the browser was picking. Throttling changes the timing of LCP but not the identity of the element, so you can identify the culprit even on a fast machine with a PerformanceObserver pasted into the console:
new PerformanceObserver((list) => {
const e = list.getEntries().at(-1);
console.log({
tag: e.element?.tagName,
text: e.element?.innerText?.slice(0, 80),
sizePx: Math.round(e.size),
renderTimeMs: Math.round(e.renderTime || e.startTime),
});
}).observe({ type: "largest-contentful-paint", buffered: true });The last entry is the final candidate. In my case the logged text was the first 80 characters of the consent copy, which ended the guessing immediately.
One measurement trap cost me about an hour, twice. My local DevTools Lighthouse kept reporting a bad LCP even after the fix, plus Best Practices warnings about deprecated APIs I never used. The cause was my own browser profile: privacy and ad-blocking extensions inject scripts into every page, and in a throttled lab run that extra main-thread work inflates LCP. The same URL on pagespeed.web.dev, which runs in a clean environment, scored 98 for Performance and 100 for Best Practices. Local Lighthouse measures your browser profile, not your page; for pass or fail decisions use PageSpeed Insights or a clean headless run, and treat field data as the final word over both.
The options on the table
With the culprit confirmed, I listed every way out I could think of before writing any code. There were five.
Option 1: stop deferring and server-render the banner. Put it in the static HTML so it paints together with the content at FCP, making the FCP to LCP gap zero by construction. Pros: it attacks the root cause, works identically in lab and field, and the banner is genuinely visible sooner, which is arguably better UX for a prompt that legally wants an answer. Cons: the deferral existed for a reason. The banner must not appear for returning visitors who already decided, and a naively server-rendered banner would paint for everyone and then pop away after hydration, trading an LCP problem for a flash.
Option 2: gate it server-side with a cookie. Store the decision in a cookie, read it on the server, and simply not render the banner for returning visitors. Pros: the cleanest markup, no client gate at all. Cons: a disqualifying one. Reading request cookies makes the route dynamic, and a dynamic route stops being CDN-cacheable. I would have traded an LCP problem for serving every page view from origin, which is the exact cost the static setup existed to remove.
Option 3: defer even further, until first interaction. LCP stops updating at the first input, so a widget mounted on the first scroll or pointer event never becomes a candidate. Pros: it genuinely helps field data and keeps the banner out of the initial critical path. Cons: lab runs do not interact, so any fallback timer you add for users who never touch the page still gets caught by PageSpeed Insights, and the lab score barely moves. It also delays a prompt that regulation wants shown promptly.
Option 4: make the banner not contentful. Shrink it below the size of the real content, or reserve its space with a placeholder skeleton. Pros: minimal code. Cons: fragile in the first variant and broken in the second. The size race silently flips the day someone adds a paragraph of legal copy or a visitor arrives on a smaller viewport. And the skeleton variant does not work at all: an empty reserved box is not a contentful element, so it cannot become an early LCP candidate on the banner's behalf; LCP still waits for the real text to paint.
Option 5: promote a different element to LCP. Restructure the hero so the intended content outweighs the banner: a larger heading, or a hero image loaded eagerly with fetchpriority="high" and a preload hint so it paints early and big. Pros: it aligns the metric with what users actually came for, and priority hints on a real hero are good hygiene anyway. Cons: it is the same fragile size race as option 4 from the other side, and redesigning a page to outweigh a cookie bar is letting the metric drive the design. On this page the content was text; inventing a hero image for it had no product justification.
What I chose and why
I chose option 1, with a small addition that neutralizes its only real con. The deciding logic: options 4 and 5 depend on relative sizes and break silently, option 3 cannot move the lab score, and option 2 sacrifices cacheability, which was a hard constraint. Option 1 is the only one that fixes the metric by construction, in lab and field alike, no matter how big the banner grows later.
The addition is a pre-paint gate for returning visitors, in three small pieces. First, a tiny inline script in the document head. It runs before any framework code and before the first frame, reads the stored decision, and marks the document if one exists:
try {
if (localStorage.getItem("my_consent"))
document.documentElement.dataset.consent = "resolved";
} catch (e) {}Second, one CSS rule that hides the server-rendered banner before it ever paints for people who already decided:
html[data-consent="resolved"] [data-consent-banner] {
display: none;
}Third, the component itself loses its mount-gate. It renders on the server unconditionally; React only wires up the buttons and removes the element once a decision is made:
"use client";
export function ConsentBanner() {
// SSR and the first client render must agree
const [resolved, setResolved] = useState(false);
useEffect(() => {
if (getConsent() !== "unknown") setResolved(true);
}, []);
if (resolved) return null;
return <div data-consent-banner role="dialog">...</div>;
}This combination keeps everything option 2 promised without its cost. The server always renders the same HTML, banner included, so the page stays static and CDN-cacheable; personalization happens in the two milliseconds of inline script before paint. On a first visit the banner paints with the content, and the measured FCP to LCP gap dropped from 4.5 seconds to zero. For returning visitors the CSS rule removes the banner before paint, so it is never a contentful candidate and LCP falls back to the actual page content. No flash in either case. The same pattern, static HTML plus a pre-paint client gate, generalizes to dark mode, dismissed announcement bars, and any other "remember my choice" UI.
Two details that bit me during the rewrite:
- If the banner is suppressed on some routes, decide that from the router's pathname hook so the server render and the first client render agree, otherwise you trade an LCP problem for a hydration mismatch.
- The inline script mutates an attribute on
<html>, so that element needssuppressHydrationWarning. If you already run a dark mode anti-flash script you likely have it.
Lessons
- A large FCP to LCP gap on a static page is a smell. Content paints at FCP; if LCP lands seconds later, something large is painting late, and it is probably not your content.
- Deferral makes LCP worse, not better. LCP tracks the largest element whenever it paints. For a big element the only durable fix is painting it earlier or removing it before paint.
- Identify the LCP element, do not guess. A five-line
PerformanceObservernames the exact element, and throttling changes timing, not identity. - Enumerate options before coding. Four of my five candidate fixes had a hidden flaw (size races, lab blindness, lost cacheability) that only surfaced when I wrote the cons down next to the pros.
- Distrust local Lighthouse, trust clean environments. Browser extensions inflate lab LCP and trigger Best Practices warnings that have nothing to do with your page.
- Client-side gates preserve cacheability. An inline pre-paint script plus one CSS rule personalizes a static page without making the route dynamic.
- SSR-ing a client widget has hydration edges. Route suppression must match between server and client, and pre-paint attribute mutation needs
suppressHydrationWarning.