Skip to content
PS

18 July 2026 ·

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:

  • priority preloads 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" />;
  • afterInteractive for things you want soon but not blocking (most analytics).
  • lazyOnload for 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:

  1. Find the LCP element in DevTools. It's almost always an image.
  2. Put it through next/image with priority and a real sizes.
  3. Move fonts to next/font.
  4. Delete a "use client" — push it to the leaf that needs it.
  5. 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.