AboutBlogContact
DevelopmentApril 18, 2026 7 min read 3

Web Vitals & Lighthouse 100: Practical Optimization Guide 2026

AunimedaAunimeda
📋 Table of Contents

Web Vitals & Lighthouse 100: Practical Optimization Guide 2026

Lighthouse 100 on a blank page is meaningless. This guide targets a real Next.js production application — with dynamic data, authentication, images, and third-party scripts. These are specific changes, not generic "minify your CSS" advice.


The 2024-2026 Core Web Vitals Landscape

Google's Core Web Vitals updated in March 2024:

Metric Measures Good Needs Work Poor
LCP Largest Contentful Paint ≤2.5s 2.5-4s >4s
INP Interaction to Next Paint (replaced FID) ≤200ms 200-500ms >500ms
CLS Cumulative Layout Shift ≤0.1 0.1-0.25 >0.25

INP replaced FID (First Input Delay) as of March 2024. INP measures the worst interaction throughout the page lifecycle, not just the first one. This changes optimization strategy.


LCP: Largest Contentful Paint

LCP is almost always the hero image or above-the-fold heading. Find it:

# Simulate slow connection in Chrome DevTools
# Network → Slow 4G → Check LCP element in Lighthouse

Fix 1: Preload the LCP Image

// app/layout.tsx or page.tsx
import { headers } from 'next/headers';

// For Next.js Image component — priority prop preloads automatically
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority            // ← generates <link rel="preload"> in <head>
  quality={85}
  sizes="100vw"
/>

Without priority, Next.js lazy-loads images. Your hero image arrives late = bad LCP.

Fix 2: Image Format and Size

// next.config.ts
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'], // AVIF first (30% smaller than WebP)
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    minimumCacheTTL: 60 * 60 * 24 * 7, // 7 days
  },
};

Fix 3: Preconnect to External Resources

// app/layout.tsx <head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.yourapi.com" />

Fix 4: TTFB (Time to First Byte)

TTFB directly impacts LCP. For Next.js:

// Export static pages where possible
export const dynamic = 'force-static'; // cached at CDN edge

// For dynamic pages — use Suspense to stream early
export default function Page() {
  return (
    <>
      <StaticHeader />  {/* Sent immediately */}
      <Suspense fallback={<Skeleton />}>
        <DynamicContent />  {/* Streams when ready */}
      </Suspense>
    </>
  );
}

Vercel/Cloudflare edge caching + streaming reduces TTFB to <100ms for most users.


INP: Interaction to Next Paint

INP is about every interaction, not just the first. Long tasks (>50ms on main thread) are the culprit.

Identify Long Tasks

// In your app — monitor long tasks
if (typeof PerformanceObserver !== 'undefined') {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.duration > 50) {
        console.warn('Long task:', entry.duration, 'ms', entry);
      }
    }
  });
  observer.observe({ type: 'longtask', buffered: true });
}

Fix 1: Defer Non-Critical JavaScript

// Don't import heavy libraries synchronously
// ❌
import { Chart } from 'chart.js';

// ✅ Dynamic import — loads only when needed
const Chart = dynamic(() => import('chart.js'), { ssr: false });

// Or import on interaction
async function handleChartOpen() {
  const { Chart } = await import('chart.js');
  // Initialize chart
}

Fix 2: Break Up Synchronous Work

// ❌ Blocking: processes 10000 items synchronously
function processAllItems(items: Item[]): Result[] {
  return items.map(expensiveTransform);
}

// ✅ Yield to browser between chunks
async function processInChunks(items: Item[], chunkSize = 100): Promise<Result[]> {
  const results: Result[] = [];
  
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    results.push(...chunk.map(expensiveTransform));
    
    // Yield to browser — allows user interactions to be processed
    await new Promise(resolve => setTimeout(resolve, 0));
  }
  
  return results;
}

Fix 3: useTransition for Non-Urgent Updates

'use client';
import { useTransition, useState } from 'react';

export function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value); // Immediate — input stays responsive
    
    startTransition(() => {
      // Deferred — doesn't block input interactions
      setResults(expensiveSearch(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <ResultsList results={results} />}
    </>
  );
}

CLS: Cumulative Layout Shift

Layout shifts happen when elements move after initial render. Common causes:

Fix 1: Always Set Image Dimensions

// ❌ No dimensions → browser doesn't reserve space → shift when image loads
<img src="/hero.jpg" alt="Hero" />

// ✅ Explicit dimensions → browser reserves space
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} />

// ✅ Fill with aspect-ratio container
<div style={{ position: 'relative', aspectRatio: '16/9' }}>
  <Image src="/hero.jpg" alt="Hero" fill style={{ objectFit: 'cover' }} />
</div>

Fix 2: Reserve Space for Dynamic Content

// ❌ Ads/widgets that appear after JS loads cause shift
<div id="ad-slot" />

// ✅ Reserve space before content loads
<div style={{ minHeight: 250, minWidth: 300 }}>
  <AdSlot />
</div>

Fix 3: Font Loading Strategy

Fonts are a major CLS source. Text renders in system font, then jumps when web font loads.

// next/font — zero CLS, zero FOUT
import { Inter, JetBrains_Mono } from 'next/font/google';

const inter = Inter({
  subsets: ['latin', 'cyrillic'],
  display: 'swap',          // show text immediately
  preload: true,
  variable: '--font-inter', // CSS variable for Tailwind
});

// layout.tsx
<html lang="ru" className={`${inter.variable}`}>

next/font self-hosts fonts, inlines the font-face CSS, and generates optimal font-display — eliminating FOUT entirely.


JavaScript Bundle Analysis

# Analyze bundle size
ANALYZE=true npm run build

# Or with @next/bundle-analyzer
npm install @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';

const withAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});

export default withAnalyzer({
  // your config
});

The bundle analyzer shows which packages are largest. Common offenders:

  • moment.js (67KB) → replace with date-fns (tree-shakeable)
  • lodash (70KB) → use individual imports import debounce from 'lodash/debounce'
  • @mui/material (full import) → use specific imports

Check What's in Your Chunks

# See largest imports
npx webpack-bundle-analyzer .next/static/chunks/

Third-Party Script Optimization

Google Analytics, Meta Pixel, and chat widgets are INP killers.

// ❌ Blocking third-party scripts
<script src="https://www.googletagmanager.com/gtm.js?id=GTM-XXX" />

// ✅ Next.js Script with afterInteractive strategy
import Script from 'next/script';

<Script
  src="https://www.googletagmanager.com/gtm.js?id=GTM-XXX"
  strategy="afterInteractive"  // loads after page is interactive
/>

// For non-critical scripts (analytics, A/B testing):
<Script
  src="https://cdn.example.com/widget.js"
  strategy="lazyOnload"  // loads during browser idle time
/>

Critical CSS

// next.config.ts
// Beasties (successor to critters) inlines critical CSS
// Install: npm install beasties
const nextConfig = {
  experimental: {
    optimizeCss: true, // uses beasties internally
  },
};

This inlines above-the-fold CSS directly into HTML, eliminating render-blocking stylesheets.


Monitoring in Production

Lighthouse in DevTools is synthetic. Real user performance via Web Vitals API:

// lib/vitals.ts
import type { Metric } from 'web-vitals';

export function reportWebVitals(metric: Metric) {
  const body = {
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating,
    id: metric.id,
    navigationType: metric.navigationType,
  };

  // Send to your analytics endpoint
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify(body),
    keepalive: true, // survives page unload
  });
}
// app/layout.tsx
// Next.js built-in support
export function reportWebVitals(metric: NextWebVitalsMetric) {
  if (process.env.NODE_ENV === 'production') {
    sendToAnalytics(metric);
  }
}

Track p75 (75th percentile), not average. Google measures at p75. If 25% of your users have poor CLS, you fail the CWV assessment regardless of your median.


Lighthouse 100 Checklist

Performance:
[ ] LCP image has priority prop
[ ] Images have explicit width/height or aspectRatio container
[ ] next/font for all web fonts
[ ] AVIF/WebP formats configured
[ ] Bundle analyzed — no accidentally large imports
[ ] Heavy components lazy loaded
[ ] Third-party scripts use afterInteractive/lazyOnload
[ ] Critical CSS inlined (beasties/optimizeCss)
[ ] Static pages exported where possible

Accessibility (affects Lighthouse score):
[ ] All images have meaningful alt text
[ ] Color contrast ratio ≥ 4.5:1 for normal text
[ ] Form inputs have associated labels
[ ] Interactive elements have accessible names

SEO:
[ ] Every page has unique title and meta description
[ ] robots.txt and sitemap.xml present
[ ] Canonical URLs configured
[ ] Structured data (Schema.org) where applicable

Best Practices:
[ ] HTTPS everywhere
[ ] No mixed content
[ ] No browser errors in console
[ ] Security headers configured

Aunimeda builds performance-optimized Next.js applications. Start a project.

See also: Next.js 15 Server Components deep dive, Clean Architecture in Node.js

Read Also

Node.js vs Bun vs Deno in 2026: Runtime Comparison with Real Benchmarksaunimeda
Development

Node.js vs Bun vs Deno in 2026: Runtime Comparison with Real Benchmarks

Bun 1.x is production-stable. Deno 2.0 supports npm packages. Node.js 22 has native TypeScript. The runtime landscape changed. Here's what the numbers actually show and when each runtime makes sense for real projects.

Kaspi Pay API Integration Guide for Web and Mobile Apps (2026)aunimeda
Development

Kaspi Pay API Integration Guide for Web and Mobile Apps (2026)

The complete developer guide to integrating Kaspi Pay in your web or mobile application. Authentication, payment flow, webhooks, and handling edge cases for the Kazakhstan market.

2GIS Maps Flutter Integration Guide: Maps for CIS Apps (2026)aunimeda
Development

2GIS Maps Flutter Integration Guide: Maps for CIS Apps (2026)

How to integrate 2GIS Maps SDK into your Flutter app. Map display, markers, routing, search, and geolocation - with full code examples for iOS and Android.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles