Skip to content
kobiakov.dev
Назад в блог

Web Performance in 2026: LCP, CLS, INP, Fonts, and Cloudinary

A practical guide to achieving green Core Web Vitals in 2026 — real measurements, Cloudinary image optimization, font loading strategy, and the new INP metric.

Vladyslav Kobiakov4 мин чтения
Web Performance in 2026: LCP, CLS, INP, Fonts, and Cloudinary

Google's Core Web Vitals stopped being an abstract SEO concern the moment they became a ranking signal. In 2026, passing the three thresholds — LCP under 2.5s, CLS under 0.1, INP under 200ms — is table stakes for any site that competes on organic search. This post is about what actually moves those numbers on a Next.js site.

The 2026 Metric Landscape

Google officially retired FID (First Input Delay) and replaced it with INP (Interaction to Next Paint) in March 2024. INP measures the latency of the slowest interaction during a page visit — not just the first one. This matters because FID was easy to game by deferring JavaScript; INP catches slow click handlers anywhere in the session.

Current thresholds:

| Metric | Good | Needs Improvement | Poor | |---|---|---|---| | LCP | ≤ 2.5s | 2.5s – 4.0s | > 4.0s | | CLS | ≤ 0.1 | 0.1 – 0.25 | > 0.25 | | INP | ≤ 200ms | 200ms – 500ms | > 500ms |

LCP: The Image is Almost Always the Culprit

Largest Contentful Paint is typically the hero image or the <h1>. On a portfolio site, it's almost always the hero image.

Priority hint + preload

Tell the browser immediately that the hero image is critical:

// HeroSection.tsx import Image from 'next/image'; export function HeroSection() { return ( <Image src="/hero.webp" alt="Hero" width={1200} height={630} priority // disables lazy loading, adds fetchpriority="high" quality={85} /> ); }
tsx

next/image with priority generates a <link rel="preload"> in the <head> automatically. The browser fetches the image in parallel with HTML parsing.

Cloudinary for responsive images

Serving a 1200px image to a 390px mobile screen wastes ~60% of bandwidth. Cloudinary's URL API handles responsive sizing without storing multiple variants:

// lib/cloudinary.ts export function cloudinaryUrl( publicId: string, options: { width: number; quality?: number; format?: string } = { width: 800 } ) { const { width, quality = 'auto', format = 'auto' } = options; return `https://res.cloudinary.com/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/image/upload/w_${width},q_${quality},f_${format}/${publicId}`; }
ts

Combined with next/image's sizes prop:

<Image src={cloudinaryUrl('projects/hero', { width: 1200 })} sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" fill alt="Project hero" priority />
tsx

The browser picks the appropriate size based on viewport. A 390px screen downloads a ~390px image, not a 1200px one.

LCP debugging

Use Chrome DevTools' Performance panel with "LCP candidate" markers, or Lighthouse in Node CI:

npx lighthouse https://kobiakov.dev --output json | \ jq '.audits["largest-contentful-paint"].displayValue'
bash

A good rule: if the LCP element is an image, its URL should appear in the <head> as a preload or in the initial HTML (not injected by JavaScript).

CLS: Layout Shift Comes From Three Places

Cumulative Layout Shift measures unexpected movement of page elements. In practice, 90% of CLS comes from:

  1. Images without explicit dimensions — the browser doesn't know how much space to reserve
  2. Web fonts loading late — text reflows when the custom font swaps in
  3. Dynamic content injected above existing content — cookie banners, late-loading ads

Images

Always set width and height on <Image>. For responsive images with fill, wrap in a container with explicit aspect ratio:

<div className="relative aspect-video"> <Image src={src} alt={alt} fill className="object-cover" /> </div>
tsx

aspect-video (16:9) reserves exactly the right space. No shift when the image loads.

Fonts

The worst font loading strategy is the default: no font-display, no preload, fonts loaded from a third-party CDN.

// app/layout.tsx — next/font (zero-CLS approach) import { Inter, JetBrains_Mono } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', // show fallback immediately, swap when ready variable: '--font-inter', preload: true, });
ts

next/font downloads fonts at build time and self-hosts them from your domain. No cross-origin request, no CLS from late font swap. The display: 'swap' plus size-adjust CSS (automatically added by next/font) shrinks the fallback font to match the custom font metrics — visual shift is imperceptible.

INP: The New FID Replacement

INP > 200ms usually means one of:

  • A click handler doing synchronous work on the main thread
  • A React component re-rendering too much on interaction
  • Third-party scripts blocking the main thread

Identify long tasks

In Chrome DevTools Performance panel, look for tasks longer than 50ms (marked in red). Filter by "Long tasks" in the Activity tab.

Break up heavy work with scheduler

If you must do CPU-heavy work on user interaction, yield to the browser between chunks:

async function processLargeList(items: Item[]) { const CHUNK_SIZE = 50; for (let i = 0; i < items.length; i += CHUNK_SIZE) { const chunk = items.slice(i, i + CHUNK_SIZE); processChunk(chunk); // Yield to browser: allows paint + input handling await new Promise(resolve => scheduler.postTask(resolve, { priority: 'background' })); } }
ts

Reduce React re-renders

INP spikes on filter/search interactions often trace back to a component re-rendering its entire list. useMemo for derived data, React.memo for list items:

const filteredProjects = useMemo( () => projects.filter(p => p.tags.includes(activeTag)), [projects, activeTag] );
ts

Measuring in CI

Don't rely on local Lighthouse runs. Real user performance varies. Add automated measurement to your CI pipeline:

# .github/workflows/perf.yml - name: Lighthouse CI uses: treosh/lighthouse-ci-action@v12 with: urls: | https://staging.kobiakov.dev/ https://staging.kobiakov.dev/blog budgetPath: ./lighthouse-budget.json uploadArtifacts: true
yaml
// lighthouse-budget.json [{ "path": "/*", "timings": [ { "metric": "largest-contentful-paint", "budget": 2500 }, { "metric": "interactive", "budget": 3500 } ], "resourceSizes": [ { "resourceType": "script", "budget": 300 }, { "resourceType": "image", "budget": 500 } ] }]
json

A failed budget breaks the CI build before the performance regression reaches production.

Quick Wins Summary

| Change | Impact | |---|---| | Add priority to hero image | LCP -0.5 to -1.5s | | Switch to next/font | CLS -0.05 to -0.15 | | Add explicit dimensions to all images | CLS near zero | | Cloudinary f_auto,q_auto | LCP -0.2 to -0.8s on mobile | | Defer non-critical JS (next/script strategy="lazyOnload") | INP -20 to -80ms |

Each of these takes under 30 minutes to implement. Combined, they typically move a 60-70 Lighthouse score into the 95+ range.

Share: