I recently audited my blog’s performance and was shocked — my Lighthouse score had dropped from 98 to 74 over six months. A few careless additions had destroyed my Core Web Vitals. Here is how I diagnosed and fixed every issue, with real numbers.
Starting Point: The Audit
Before fixing anything, I ran a full performance audit using three tools:
- Lighthouse 12: Overall score 74, LCP 4.2s, CLS 0.18
- WebPageTest: Time to Interactive 5.8s, First Byte 180ms
- Chrome DevTools Performance panel: Revealed layout shifts and excessive main thread work
The two biggest offenders: unoptimized images (contributing 2.1s to LCP) and render-blocking JavaScript (adding 1.3s to TTI). Let me walk through how I fixed each one.
Image Optimization: The Biggest Win
Images are typically the largest assets on a page. I had 12 hero images loading at full resolution — some were 3MB each. Here’s my optimization pipeline:
Modern Formats: WebP and AVIF
I switched all images to AVIF with WebP fallback. The file size reduction was dramatic:
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img
src="hero.jpg"
alt="Hero image"
width="1200"
height="630"
loading="lazy"
decoding="async"
/>
</picture>
Before: JPEG hero image — 2.4MB
After: AVIF version — 180KB (93% reduction)
Responsive Images with srcset
Don’t serve a 2400px image to a 375px phone screen. Use srcset to serve the right size:
<img
srcset="
hero-400w.avif 400w,
hero-800w.avif 800w,
hero-1200w.avif 1200w,
hero-1600w.avif 1600w
"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 1200px"
src="hero-1200w.avif"
alt="Hero image"
loading="lazy"
decoding="async"
/>
The Image CDN Approach
For dynamic sites, use an image CDN that transforms on-the-fly:
<!-- Cloudflare Image Resizing -->
<img src="https://example.com/cdn-cgi/image/width=800,format=auto/original.jpg" />
<!-- Next.js Image Component (auto-optimizes) -->
import Image from 'next/image';
<Image
src="/hero.jpg"
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 50vw"
priority={true}
/>
Image optimization alone improved my LCP from 4.2s to 1.8s. That’s a 57% improvement from one change.
JavaScript: The Silent Killer
My blog had accumulated three analytics scripts, a chat widget, and an email popup — all loaded synchronously in the <head>. Each one was adding 200-400ms to my page load.
Audit Your Scripts
I used Chrome DevTools’ Coverage tab to find unused JavaScript. The results were embarrassing:
- Main bundle: 45KB gzipped (acceptable)
- Analytics scripts: 120KB gzipped (loaded on every page)
- Chat widget: 85KB gzipped (loaded even though 90% of visitors never open it)
The Fix: Dynamic Imports
I moved non-critical scripts to load after the page was interactive:
<!-- Instead of this (render-blocking) -->
<script src="https://analytics.example.com/script.js"></script>
<!-- Do this (deferred) -->
<script>
// Load analytics after the page is interactive
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
const script = document.createElement('script');
script.src = 'https://analytics.example.com/script.js';
document.head.appendChild(script);
});
} else {
// Fallback for browsers without requestIdleCallback
window.addEventListener('load', () => {
setTimeout(() => {
const script = document.createElement('script');
script.src = 'https://analytics.example.com/script.js';
document.head.appendChild(script);
}, 2000);
});
}
</script>
Tree Shaking in Practice
If you’re using a bundler (Vite, webpack, esbuild), make sure tree shaking actually works. I found that importing an entire library when I only needed one function was a common problem:
// BAD: Imports entire lodash (70KB)
import _ from 'lodash';
_.debounce(fn, 300);
// GOOD: Import only what you need (1KB)
import debounce from 'lodash/debounce';
debounce(fn, 300);
// BETTER: Use native (0KB)
let timeoutId;
function debounce(fn, delay) {
clearTimeout(timeoutId);
timeoutId = setTimeout(fn, delay);
}
After removing unused JavaScript, my Time to Interactive dropped from 5.8s to 2.1s.
Caching Strategies That Actually Work
Caching is where most developers get confused. Here’s the simple mental model:
The Cache-First Approach (For Static Assets)
For assets that rarely change (fonts, CSS, images), cache aggressively:
// Service Worker caching for static assets
const STATIC_CACHE = 'static-v3';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll([
'/css/global.css',
'/fonts/inter-var.woff2',
'/js/main.js',
]);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// Return cached version, fetch in background to update
if (response) {
fetch(event.request).then((freshResponse) => {
caches.open(STATIC_CACHE).then((cache) => {
cache.put(event.request, freshResponse);
});
});
return response;
}
return fetch(event.request);
})
);
});
The Stale-While-Revalidate Pattern
For content that changes occasionally (blog posts, user profiles):
// Next.js revalidation
export const revalidate = 3600; // Revalidate every hour
// Or on-demand revalidation
export async function POST(request) {
const { tag } = await request.json();
revalidateTag(tag);
return Response.json({ revalidated: true });
}
// Fetch with cache tags
const post = await fetch('/api/posts/123', {
next: { tags: ['post-123'] },
});
CDN Caching Headers
Set the right Cache-Control headers for different asset types:
# Static assets (hashed filenames like main.a1b2c3.js)
Cache-Control: public, max-age=31536000, immutable
# HTML pages (need fresh content)
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400
# API responses
Cache-Control: private, no-cache
Layout Shift Prevention
Layout shifts (CLS) are the most frustrating user experience issue. Users click something, the page shifts, and they click the wrong button. Here’s how I eliminated CLS to 0:
Always Set Image Dimensions
Never load an image without explicit width and height:
<!-- BAD: Image loads and pushes content down -->
<img src="photo.jpg" alt="Photo" />
<!-- GOOD: Browser reserves space immediately -->
<img src="photo.jpg" alt="Photo" width="800" height="600" />
<!-- For dynamically sized images, use aspect-ratio -->
<style>
.aspect-ratio-16-9 {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
}
</style>
<img class="aspect-ratio-16-9" src="photo.jpg" alt="Photo" />
Font Loading Strategy
Web fonts cause layout shifts when they load. Use font-display: optional for body text:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: optional; /* Use system font if not cached */
unicode-range: U+0000-00FF;
}
/* For critical UI elements, use swap */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-bold.woff2') format('woff2');
font-display: swap; /* Show system font, then swap */
}
The Results
After all optimizations, here’s the before/after comparison:
| Metric | Before | After |
|---|---|---|
| Lighthouse Score | 74 | 98 |
| LCP | 4.2s | 1.1s |
| CLS | 0.18 | 0.01 |
| TTI | 5.8s | 1.9s |
| Total Page Weight | 4.2MB | 890KB |
The Tools I Use
Performance optimization is a continuous process. These are the tools I check weekly:
- Lighthouse CI: Automate performance checks on every deploy
- Chrome DevTools Coverage: Find unused JavaScript
- WebPageTest: Real-world performance from different locations
- Core Web Vitals Report in Search Console: See how real users experience your site
Conclusion
Web performance isn’t about chasing a perfect Lighthouse score — it’s about respecting your users’ time and bandwidth. Every 100ms of latency costs you conversions. The techniques above aren’t theoretical — they’re the exact changes I made to cut my page weight by 79% and load time by 68%.
Start with images (biggest impact), then tackle JavaScript (second biggest), then fine-tune caching. Don’t try to optimize everything at once — measure, fix one thing, measure again. That’s the only way to make real progress.