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
onClickhandlers, 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
widthandheightattributes 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-adjustin your@font-faceto 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/imagefor 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:visiblefor 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.
