18 July 2026 · Piotr Józef Schumann
Core Web Vitals on a Next.js marketing site: LCP from 4s to 1.2s
A client came to me with a Next.js marketing site that looked fine on my laptop and felt broken on a phone. The number that mattered was Largest Contentful Paint: 4.0s on a mid-tier Android over 4G. Google's "good" threshold is 2.5s. The site was losing the visitors it paid ads to acquire before the hero even rendered.
Here's exactly what I found, what I changed, and how much each change actually moved the needle. No silver bullet — LCP is death by a thousand cuts, and it's fixed the same way.
Measure first, in the right conditions
The mistake I see most often: people optimise against a desktop Lighthouse run on a fast connection and declare victory. That's not where users are.
I measure LCP three ways:
- Lighthouse in Chrome DevTools, but with mobile emulation and the "Slow 4G" + 4× CPU throttle preset. This is your repeatable lab number.
- WebPageTest on a real device profile, because emulation lies about CPU cost.
- Field data from the Chrome UX Report (CrUX) — the 75th percentile of real visitors. Lab tells you what's possible; the field tells you what's happening.
On this site the lab number was 4.0s and the field p75 was worse. Good — they agreed on the direction.
Find the LCP element
Open the Performance panel, record a load, and look at the "LCP" marker. DevTools tells you the exact element. Here it was the hero <img> — a 1.4 MB JPEG rendered at full viewport width, served at its original 3000px size, and not using next/image.
That single fact explained most of the 4 seconds: a huge, unoptimised, render-blocking-adjacent image with no priority hint, no modern format, and no width constraints.
Fix 1 — the hero image (the big one)
Switching the hero to next/image with a priority hint did more than everything else combined:
import Image from "next/image";
import hero from "@/public/hero.jpg";
export function Hero() {
return (
<Image
src={hero}
alt="..."
priority
sizes="100vw"
className="h-[60vh] w-full object-cover"
placeholder="blur"
/>
);
}
What each part buys you:
prioritypreloads the image and takes it out of lazy-loading, so the browser fetches your LCP element immediately instead of discovering it late.- Automatic AVIF/WebP cut the transfer from 1.4 MB to ~180 KB.
sizes="100vw"lets Next generate correctly-sized sources, so a phone downloads a phone-sized image, not a 3000px one.placeholder="blur"removes the jarring pop-in and stabilises layout.
LCP after this one change: 4.0s → 2.1s.
Fix 2 — fonts that stop blocking
The site loaded two web fonts from a third-party CDN with a default <link>. That's a render-blocking request on a critical path, plus a layout shift when the font swapped in.
next/font self-hosts the files, removes the extra DNS/connection, and handles font-display for you:
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
});
Self-hosting killed a round-trip to a third-party origin and the swap behaviour meant text painted immediately in a fallback, then upgraded. Smaller win on LCP, bigger win on CLS.
LCP: 2.1s → 1.8s.
Fix 3 — ship less JavaScript
The hero section was a Client Component ("use client") for one reason: a single animated counter far below the fold. That forced the whole subtree to hydrate before it settled.
I made the page a Server Component and pushed "use client" down to the one leaf that actually needed interactivity. Less JS shipped, less main-thread work competing with the image decode.
The rule I follow now: a component is a Server Component until proven otherwise. Interactivity is a leaf concern, not a page concern.
LCP: 1.8s → 1.4s.
Fix 4 — get third-party scripts off the critical path
Analytics and a chat widget were loading with plain <script> tags in <head>. next/script with the right strategy defers them until they can't hurt the paint:
import Script from "next/script";
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />;
afterInteractivefor things you want soon but not blocking (most analytics).lazyOnloadfor anything non-essential (chat, heatmaps).
The chat widget alone was stealing ~300ms of main thread during load. Deferring it was free.
LCP: 1.4s → 1.2s.
Fix 5 — render statically where you can
The homepage was fetching CMS content on every request with no caching, so each visit paid for the round-trip. This page's content changes maybe weekly. Making it statically rendered (and revalidating on a schedule) meant the HTML shell was served from the edge instantly, and LCP stopped depending on an origin fetch at all.
The before/after
| Metric (mobile, Slow 4G) | Before | After | | ------------------------ | ------ | ----- | | LCP | 4.0s | 1.2s | | Hero transfer | 1.4 MB | 180 KB | | Total JS | high | ~40% less | | CLS | 0.18 | 0.02 |
What I'd tell you to check first
If you have one hour and a slow Next.js site, do these in order:
- Find the LCP element in DevTools. It's almost always an image.
- Put it through
next/imagewithpriorityand a realsizes. - Move fonts to
next/font. - Delete a
"use client"— push it to the leaf that needs it. - Send every third-party script through
next/script.
None of this is clever. LCP rewards discipline, not tricks: serve the right-sized bytes for the most important element, and stop everything else from getting in its way.