EmpireUI
Get Pro
← Blog8 min read#core-web-vitals#next-js#performance

Core Web Vitals in 2026: LCP, INP, CLS with Real Next.js Fixes

LCP, INP, and CLS are still killing your scores in 2026. Here's how to actually fix them in Next.js with real code — not vague advice.

Developer looking at web performance metrics and charts on a monitor

Why Your Core Web Vitals Are Still Red in 2026

Honestly, most teams treat Core Web Vitals as a one-time checklist rather than an ongoing system — and then wonder why their Lighthouse scores tank after shipping a new feature. Google has been using CWV as a ranking signal since 2021, and in 2026 the thresholds are just as unforgiving as ever. LCP under 2.5s. INP under 200ms. CLS under 0.1. Miss any of these in field data and you're giving up ranking positions.

The thing that trips up most Next.js devs is the gap between lab scores and field data. You can get a perfect 100 in Lighthouse locally and still fail CrUX data because your real users are on mid-tier Android phones on a congested 4G network. PageSpeed Insights pulls from the Chrome User Experience Report, which measures actual visitors. That's the number that matters to Google.

This article is about field-data fixes. Not theoretical ones. We'll go metric by metric with the patterns that actually move the needle in production Next.js apps.

LCP: Largest Contentful Paint — Stop Letting Your Hero Image Load Last

LCP is almost always either a hero image or an H1 text block. The fix path is different for each. For images, you want to eliminate every millisecond between the browser starting to parse HTML and starting to download your hero image. That means no lazy loading on above-the-fold images, correct sizes attribute, and AVIF/WebP format served via next/image.

The single biggest LCP win most teams miss is fetchpriority="high" on the LCP image. Next.js 14+ exposes this as the priority prop on <Image>. Set it. The browser will issue a high-priority fetch for that image before it even finishes parsing the page. Without it, the image competes with scripts and stylesheets for bandwidth.

import Image from 'next/image'

export default function Hero() {
  return (
    <section className="relative h-[600px] w-full">
      <Image
        src="/hero.avif"
        alt="Empire UI component library preview"
        fill
        priority          // sets fetchpriority=high + preload link
        sizes="100vw"
        className="object-cover"
        quality={85}
      />
    </section>
  )
}

For text-based LCP elements, font loading is usually the culprit. If your H1 uses a custom font and you're loading it with @font-face inside a CSS file, the browser can't discover that font until it's parsed the CSS, which can't happen until the CSS file is downloaded. Use next/font instead — it inlines the @font-face declaration and handles font-display: swap automatically. You'll typically see 300-600ms shaved off LCP just from that switch.

INP: Interaction to Next Paint — The Metric Teams Forget Until It's Too Late

INP replaced FID as a Core Web Vital in March 2024, and it's a much harder metric to hit. FID only measured the delay before the browser started processing an interaction. INP measures the full round-trip: input delay + processing time + presentation delay. The 200ms budget sounds generous until you realize it includes everything from the click to the pixel on screen.

Long tasks on the main thread are the enemy. Any JavaScript that runs for more than 50ms is a long task, and it can block INP for every interaction that happens during it. The fix is breaking up work. Use scheduler.yield() in browsers that support it, or fall back to setTimeout(fn, 0) chunks. React 18's concurrent rendering helps here if you're using useTransition for non-urgent state updates.

import { useTransition, useState } from 'react'

export function FilteredList({ items }: { items: string[] }) {
  const [isPending, startTransition] = useTransition()
  const [query, setQuery] = useState('')
  const [filtered, setFiltered] = useState(items)

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const val = e.target.value
    setQuery(val) // urgent — updates input immediately

    startTransition(() => {
      // non-urgent — React can interrupt and reprioritize
      setFiltered(items.filter(i => i.includes(val)))
    })
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Filtering...</span>}
      {filtered.map(i => <div key={i}>{i}</div>)}
    </div>
  )
}

Third-party scripts are a silent INP killer. A single analytics tag or chat widget that executes on the main thread during page interaction can push your INP over 500ms. Load third-party scripts with strategy="lazyOnload" in Next.js's <Script> component, or move them to a web worker using Partytown. It's not glamorous but it works.

CLS: Cumulative Layout Shift — The One Your Design Team Causes

CLS above 0.1 means elements are visually jumping around after the page loads. It's annoying for users and it signals to Google that your page isn't stable. The three main causes are: images without explicit dimensions, dynamically injected content (ads, banners, cookie notices), and web fonts causing text reflow. Each has a known fix.

Images and iframes without width and height attributes — or a CSS aspect-ratio — cause the browser to allocate zero space for them until they load, then reflow the page. Always set explicit dimensions. With next/image this is enforced. For raw <img> tags in rich text content, apply img { width: 100%; height: auto; aspect-ratio: attr(width) / attr(height); } in your global CSS.

Font-related CLS happens when a fallback system font is displayed while the custom font loads, and then the layout shifts when the custom font applies. font-display: swap is the standard fix but it only helps if your fallback and custom font have similar metrics. Use the size-adjust, ascent-override, and descent-override CSS descriptors to match your fallback font's geometry to your custom font. next/font does this automatically since Next.js 13.2 — another reason to use it.

Dynamic content injection is trickier. If you're rendering a cookie banner, alert bar, or promotional strip after hydration, reserve the space in your layout before the content loads. A simple min-height on the container prevents the shift. Same goes for glassmorphism overlays or modal triggers that reposition surrounding content when they appear.

Measuring CWV in Next.js: Getting Real Field Data

Lighthouse is a lab tool. It's useful for debugging but it doesn't predict your CrUX scores. For field data during development, use the web-vitals npm package — the same one Next.js uses internally. Pipe the metrics to your analytics platform or just console.log them during local profiling.

// app/layout.tsx or pages/_app.tsx
import { onLCP, onINP, onCLS } from 'web-vitals'

function sendToAnalytics(metric: { name: string; value: number; id: string }) {
  // Replace with your own endpoint
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    id: metric.id,
    page: window.location.pathname,
  }))
}

// Call these in a useEffect or in a client component
onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)

Once you have real field data flowing, look at the p75 value — that's the 75th percentile, which is what Google uses for CrUX. If p75 LCP is 3.2s, 25% of your users are having a worse experience than that. Segment by device category. Mobile p75 is usually 2-3x worse than desktop. That's where the fixes actually matter.

The Chrome DevTools Performance panel has a Web Vitals track since Chrome 115. Record a session, look for the LCP marker, and trace it back to the specific resource that was the bottleneck. This is much faster than guessing.

Next.js App Router Patterns That Help (and Hurt) CWV

The App Router introduced React Server Components, which can dramatically improve LCP by shipping less JavaScript to the client. A server component that fetches data and renders HTML has zero JS hydration cost. But there's a trap: if you wrap everything in 'use client' because it's easier, you lose that benefit entirely and add hydration overhead on top.

Route segments that are CPU-bound on the server can delay TTFB, which delays LCP. Use export const dynamic = 'force-static' for pages that don't need fresh data on every request. Combined with Incremental Static Regeneration (revalidate), you get static-fast serving with periodic updates. This alone can take LCP from 2.8s to 0.9s for content-heavy pages.

Streaming with <Suspense> lets you send the initial HTML shell immediately while slower data fetches complete. The shell renders fast (great LCP), and the slower parts fill in without a full page layout shift — as long as you reserve space with skeleton placeholders. It's a natural complement to any animation-heavy component like those from Lottie animations or WebGL backgrounds where you want to show a placeholder while heavy assets load.

CSS and Tailwind Patterns That Affect CLS and LCP

Tailwind v4.0.2 ships with CSS layers and a single-pass CSS engine, which means your critical styles are inlined much more efficiently than in v3. But the pattern that kills CLS in Tailwind projects is conditionally applied classes that change element dimensions after hydration. If a dark-mode class changes padding from p-4 to p-6 after mount, that's a layout shift. Compute the correct class server-side or use CSS custom properties scoped to [data-theme] attributes.

Avoid @apply for layout-affecting utilities inside your component CSS. It increases the CSS bundle size and makes it harder to trace which styles are responsible for shifts. Inline Tailwind classes are easier to audit. For spacing that must stay consistent regardless of theme or state, use fixed values: gap-2 (8px gap) is safer than a dynamic gap that changes based on screen size or user preference toggle.

Critical CSS matters for LCP. Next.js inlines component-level CSS automatically with the App Router, but if you're loading a large external stylesheet (say, for a canvas animations library or a charting tool), defer it with media="print" onload="this.media='all'" or load it in a useEffect after hydration. The LCP image or text shouldn't have to wait for a 40kB chart library stylesheet to render.

Automating CWV Monitoring So You Don't Regress

The worst outcome is fixing your vitals, shipping two new features, and watching them degrade again without noticing. Automated monitoring prevents that. Set up a synthetic monitoring job — Playwright works well — that navigates to your key pages and reports web-vitals metrics after each deploy. Fail the deploy if p75 LCP exceeds 2.5s.

// playwright/vitals.test.js
import { test, expect } from '@playwright/test'

test('LCP under 2500ms on homepage', async ({ page }) => {
  let lcp = 0

  await page.addInitScript(() => {
    new PerformanceObserver((list) => {
      const entries = list.getEntries()
      window.__lcp = entries[entries.length - 1].startTime
    }).observe({ type: 'largest-contentful-paint', buffered: true })
  })

  await page.goto('https://your-staging-url.com')
  await page.waitForLoadState('networkidle')

  lcp = await page.evaluate(() => window.__lcp ?? 9999)
  expect(lcp).toBeLessThan(2500)
})

Pair synthetic monitoring with real user monitoring (RUM). The web-vitals beacon approach shown earlier is the simplest RUM setup. If you want something more structured, Vercel Analytics and Sentry both have built-in CWV dashboards that work with Next.js out of the box. The key is having a dashboard you actually look at — not a script that runs and logs to nowhere.

Set budget alerts. If CLS goes above 0.05 on any route, get a Slack notification. If LCP p75 jumps more than 300ms week-over-week, that's a regression worth investigating. Performance without alerting is just vibes. Does your team currently get alerted when a deploy tanks your INP? If not, that's the next thing to set up.

FAQ

Does using next/image automatically fix LCP?

It helps significantly — next/image adds width/height, serves modern formats like AVIF, and prevents layout shifts from unsized images. But LCP won't be fixed unless you also add the priority prop on your above-the-fold image. Without it, the image is still fetched at normal priority and LCP suffers.

What's the fastest way to find what's causing my LCP to be slow?

Open Chrome DevTools, go to the Performance panel, record a page load, and look for the 'LCP' marker in the Timings row. Click it and DevTools will highlight the exact element and the resource that determined it. Check the waterfall to see if it was blocked by render-blocking scripts or a late-discovered image.

My INP is bad only on mobile. What should I look at first?

Mobile CPUs are 4-6x slower at executing JavaScript than a modern laptop. Start by profiling on a real mid-tier Android device with Chrome's remote debugging. Long tasks that take 30ms on desktop often take 150ms+ on mobile. Look for large event handlers, synchronous localStorage reads, and third-party scripts that execute during interactions.

Can Tailwind CSS cause CLS?

Yes, if you're applying layout-affecting classes conditionally after hydration. The classic example is a theme toggle that changes padding or font-size post-mount. Use CSS custom properties scoped to a data attribute on the html element and toggle that attribute server-side so the correct styles are applied before paint.

Does React Server Components help with Core Web Vitals?

RSC reduces the JS bundle shipped to the client, which directly helps INP (less script to parse and execute) and can help LCP by reducing the time before the page is interactive. The caveat is that slow server-side data fetches increase TTFB, which delays LCP. Use static generation or streaming where you can.

How do I measure INP accurately during development?

Install the web-vitals package and call onINP(console.log) in your app. Then interact with your UI — click buttons, type in inputs, open menus. INP is reported at the end of the session as the worst interaction. For a more detailed breakdown, use the Web Vitals Chrome extension which shows INP attribution (which element and handler was responsible).

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

PerformanceObserver API: Measuring LCP, INP, CLS in CodeCore Web Vitals in 2026: LCP, CLS, INP — Measure and FixNext.js Image Component Deep Dive: All Props, Performance ImpactNext.js Analytics: Measuring Real User Web Vitals