I recently audited a client’s e-commerce site that was losing $50,000/month in sales because their Largest Contentful Paint was 8 seconds on mobile. After two weeks of optimization work, we got it to 1.8 seconds. Their conversion rate jumped 23%.

Web performance isn’t just a technical metric — it’s directly tied to revenue, user satisfaction, and SEO rankings. Here’s everything I’ve learned about making websites fast in 2025.

Core Web Vitals: The Metrics That Matter

Google uses three Core Web Vitals (CWV) as ranking signals. Here’s what they measure and what “good” looks like:

Metric What It Measures Good Needs Improvement Poor
LCP (Largest Contentful Paint) Loading speed ≤2.5s 2.5-4.0s >4.0s
INP (Interaction to Next Paint) Responsiveness ≤200ms 200-500ms >500ms
CLS (Cumulative Layout Shift) Visual stability ≤0.1 0.1-0.25 >0.25

INP Replaced FID in 2024

INP (Interaction to Next Paint) replaced FID (First Input Delay). The difference: FID only measured the FIRST interaction. INP measures ALL interactions throughout the page lifecycle — much harder to game, much more representative of real user experience.

// Measure INP with web-vitals library
import { onINP } from 'web-vitals';

onINP(({ value, attribution }) => {
  console.log(`INP: ${value}ms`);
  console.log(`Caused by: ${attribution.interactionTarget}`);
  console.log(`Event type: ${attribution.interactionType}`);
});

Pro Tip: The #1 cause of poor INP in my audits is JavaScript that blocks the main thread during interactions. Long-running onClick handlers, synchronous state updates that trigger expensive re-renders, and layout thrashing after user input.

The Loading Strategy Stack

1. Critical CSS Inlining

Extract and inline the CSS needed for above-the-fold content:

<head>
  <!-- Critical CSS inlined -->
  <style>
    .hero { display: flex; min-height: 80vh; }
    .nav { position: fixed; top: 0; width: 100%; }
    /* Only ~5KB of critical styles */
  </style>
  
  <!-- Non-critical CSS loaded asynchronously -->
  <link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
</head>

2. Resource Hints

<head>
  <!-- DNS prefetch for third-party domains -->
  <link rel="dns-prefetch" href="//api.example.com">
  <link rel="dns-prefetch" href="//fonts.googleapis.com">
  
  <!-- Preconnect for critical third parties -->
  <link rel="preconnect" href="https://api.stripe.com" crossorigin>
  <link rel="preconnect" href="https://cdn.example.com">
  
  <!-- Preload critical resources -->
  <link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
  <link rel="preload" href="/images/hero.webp" as="image" fetchpriority="high">
  
  <!-- Prefetch next likely navigation -->
  <link rel="prefetch" href="/pricing">
</head>

3. fetchpriority Attribute

<!-- High priority: LCP image -->
<img src="/hero.webp" fetchpriority="high" alt="Hero">

<!-- Low priority: Below-fold images -->
<img src="/footer-logo.webp" fetchpriority="low" loading="lazy" alt="Logo">

Image Optimization: The Biggest Quick Win

Images account for 50-75% of page weight on most sites. Here’s the modern approach:

Responsive Images

<picture>
  <!-- AVIF: smallest, best quality (75% browser support) -->
  <source 
    type="image/avif"
    srcset="/img/hero-400.avif 400w,
            /img/hero-800.avif 800w,
            /img/hero-1200.avif 1200w"
    sizes="(max-width: 768px) 100vw, 50vw">
  
  <!-- WebP: good fallback (97% support) -->
  <source 
    type="image/webp"
    srcset="/img/hero-400.webp 400w,
            /img/hero-800.webp 800w,
            /img/hero-1200.webp 1200w"
    sizes="(max-width: 768px) 100vw, 50vw">
  
  <!-- JPEG: universal fallback -->
  <img 
    src="/img/hero-800.jpg"
    alt="Hero image"
    width="1200"
    height="600"
    loading="eager"
    fetchpriority="high"
    decoding="async">
</picture>

Image Format Comparison

Format Quality at 80% Size (1200px photo) Browser Support
JPEG Good 180 KB 100%
WebP Better 95 KB 97%
AVIF Best 55 KB 92%
JPEG XL Best 50 KB 20% (growing)

Lazy Loading Done Right

<!-- Eager load: above-the-fold (LCP candidate) -->
<img src="/hero.webp" loading="eager" fetchpriority="high" 
     width="1200" height="600" alt="Hero">

<!-- Lazy load: below-the-fold -->
<img src="/product-1.webp" loading="lazy" 
     width="400" height="300" alt="Product">

Pro Tip: ALWAYS set width and height attributes on images to prevent layout shift (CLS). The browser uses these to calculate the aspect ratio before the image loads, reserving the correct space. This single practice eliminates most CLS issues.

JavaScript Performance

Bundle Size Budget

App Type Target Bundle Max Bundle
Marketing site <50 KB 100 KB
Blog/content <80 KB 150 KB
Web app (SPA) <150 KB 300 KB
Complex dashboard <250 KB 400 KB

Code Splitting

// Route-based code splitting (Next.js)
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./components/Chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false // Don't render on server if it's purely interactive
});

// Manual code splitting for heavy operations
const processData = async (data: RawData) => {
  const { parse } = await import('heavy-parser-lib');
  return parse(data);
};

Tree Shaking: Import What You Need

// ❌ Bad: Imports entire library (300KB)
import _ from 'lodash';
_.debounce(fn, 300);

// ✅ Good: Import only what you need (2KB)
import debounce from 'lodash/debounce';
debounce(fn, 300);

// ✅ Even better: Use native alternatives
// No import needed - debounce is simple to implement
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
  let timer: ReturnType<typeof setTimeout>;
  return (...args: Parameters<T>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

Avoiding Main Thread Blocking

// ❌ Bad: Blocks main thread during large list rendering
function renderAllItems(items: Item[]) {
  items.forEach(item => {
    const el = createItemElement(item);
    container.appendChild(el);
  });
}

// ✅ Good: Yield to main thread periodically
async function renderItemsProgressively(items: Item[]) {
  const BATCH_SIZE = 50;
  
  for (let i = 0; i < items.length; i += BATCH_SIZE) {
    const batch = items.slice(i, i + BATCH_SIZE);
    batch.forEach(item => {
      container.appendChild(createItemElement(item));
    });
    
    // Yield to main thread between batches
    await scheduler.yield();  // Or: await new Promise(r => setTimeout(r, 0));
  }
}

Font Performance

Fonts are often the hidden LCP killer:

/* Optimal font loading strategy */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;  /* Show fallback immediately, swap when loaded */
  unicode-range: U+0000-00FF; /* Only Latin characters for initial load */
}
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

Font Loading Best Practices

Strategy font-display Use When
Swap swap Body text (shows fallback immediately)
Optional optional Non-critical text (skips font if slow)
Fallback fallback Balance (100ms invisible, then fallback)
Block block Icons/symbols (must show correct font)

Pro Tip: Use size-adjust in your @font-face to minimize layout shift when the web font replaces the fallback:

@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

Server-Side Performance

Edge Caching

// Next.js: Cache at the CDN edge
export const revalidate = 3600; // Cache for 1 hour

// Explicit cache headers for custom servers
app.get('/api/products', (req, res) => {
  res.set({
    'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    'CDN-Cache-Control': 'max-age=3600',
    'Vercel-CDN-Cache-Control': 'max-age=3600'
  });
  res.json(products);
});

Streaming SSR

// Stream HTML to the browser progressively
// React 18+ with Next.js App Router does this automatically with Suspense
import { Suspense } from 'react';

export default function Page() {
  return (
    <>
      <Header />  {/* Sent immediately */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList />  {/* Streamed when data is ready */}
      </Suspense>
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews />  {/* Streamed independently */}
      </Suspense>
    </>
  );
}

Measuring Performance: The Right Way

Lab vs Field Data

Type Tools Purpose
Lab Lighthouse, WebPageTest Controlled testing, debugging
Field (RUM) CrUX, web-vitals, Vercel Analytics Real user experience

Always prioritize field data. A perfect Lighthouse score means nothing if real users on 3G connections have a terrible experience.

Setting Up Real User Monitoring

import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric: Metric) {
  // Send to your analytics service
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,  // 'good' | 'needs-improvement' | 'poor'
      url: window.location.href,
      connection: navigator.connection?.effectiveType,
    }),
    keepalive: true,
  });
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

Common Mistakes I See in Performance Audits

1. Optimizing Without Measuring

Don’t guess where the bottleneck is. Run Lighthouse, check CrUX data, profile with Chrome DevTools. Optimize the thing that’s actually slow.

2. Third-Party Script Bloat

<!-- Each of these can add 100-500ms to your load time -->
<script src="https://analytics.example.com/tracker.js"></script>
<script src="https://chat.widget.com/embed.js"></script>
<script src="https://ab-testing.com/client.js"></script>
<script src="https://heatmap.tool.com/record.js"></script>
<!-- 4 third-party scripts = potentially 2 seconds added -->

Fix: Load third-party scripts with async/defer, or better — load them after user interaction (e.g., load chat widget after scroll or after 5 seconds).

3. Not Setting Image Dimensions

Missing width/height causes layout shift every single time an image loads. This is the #1 CLS issue and the easiest to fix.

4. Render-Blocking CSS

<!-- ❌ All CSS blocks rendering -->
<link rel="stylesheet" href="/styles/everything.css">

<!-- ✅ Split and load non-critical CSS async -->
<link rel="stylesheet" href="/styles/critical.css">
<link rel="preload" href="/styles/components.css" as="style" 
      onload="this.onload=null;this.rel='stylesheet'">

5. Not Using a CDN

If your server is in US-East and your users are in Europe or Asia, they’re adding 200-400ms of latency to every request. Use a CDN (Cloudflare, Fastly, CloudFront) to serve assets from edge locations close to users.

Framework-Specific Tips

Next.js

  • Use next/image for automatic optimization
  • Enable ISR for dynamic-but-cacheable pages
  • Use React Server Components to reduce client JS

Astro

  • Already zero-JS by default — Astro vs Next.js comparison
  • Use client:visible for lazy-hydrated islands
  • Enable asset compression in astro.config

Vanilla / HTMX

  • Minimal JS by design — HTMX approach
  • Server-rendered HTML is inherently fast
  • Focus on server response time and caching

Performance Optimization Checklist

Category Action Impact
Images Use WebP/AVIF with srcset High
Images Set width/height attributes High (CLS)
Images Lazy load below-fold images Medium
JS Code split routes High
JS Tree shake imports Medium
JS Defer non-critical scripts High
CSS Inline critical CSS Medium
CSS Remove unused CSS Medium
Fonts Preload critical fonts Medium
Fonts Use font-display: swap Medium (CLS)
Server Enable compression (Brotli) High
Server Use CDN for static assets High
Server Set proper cache headers High
HTML Minimize DOM depth Low-Medium
Third-party Audit and defer scripts High

FAQ

What’s the most impactful single optimization?

Image optimization (proper formats + sizing) gives the biggest improvement for the least effort. Most sites can cut 50-70% of page weight just by switching to WebP/AVIF and using responsive srcset. Second place: removing or deferring unused JavaScript.

How do I fix poor INP (Interaction to Next Paint)?

Profile user interactions in Chrome DevTools Performance tab. Look for long tasks (>50ms) that occur during user interactions. Common fixes: break up event handlers, use requestAnimationFrame for visual updates, debounce rapid input events, and virtualize long lists.

Is HTTP/2 still relevant in 2025?

Yes, but HTTP/3 (QUIC) is now widely supported and even better. HTTP/2 eliminated the need for domain sharding and sprite sheets. HTTP/3 improves performance on unreliable connections (mobile). Most CDNs support both — ensure your server does too.

How much does web performance actually affect SEO?

Core Web Vitals are a confirmed Google ranking signal, but content relevance still dominates. However, performance indirectly affects SEO through bounce rate, dwell time, and crawl efficiency. In competitive niches where content quality is similar, performance becomes the tiebreaker.

Should I use a performance monitoring service?

Yes, if your site generates revenue. Free options: CrUX (Chrome User Experience Report), web-vitals library + your own analytics. Paid options: Vercel Analytics, SpeedCurve, Calibre, RayGun. The key is tracking FIELD data (real users) not just lab data (Lighthouse). Real users on real devices tell the true performance story.