Last month I ran Lighthouse on a client's site. Score: 34. The page loaded 4MB of JavaScript, three render-blocking stylesheets, eighteen unoptimized PNG images, and four custom fonts — all above the fold. The fix? Nothing exotic. Just fundamentals that somehow got skipped. Every technique in this article is something I've personally used to push real sites from "painfully slow" to "fast enough that nobody complains."
No theoretical fluff. Just what works.
Understand What You're Measuring: Core Web Vitals
Before you optimize anything, know what Google actually measures. Three metrics matter most:
- LCP (Largest Contentful Paint) — how long until the biggest visible element renders. Target: under 2.5 seconds.
- INP (Interaction to Next Paint) — how long between a user interaction and the next visual update. Target: under 200ms.
- CLS (Cumulative Layout Shift) — how much stuff jumps around while loading. Target: under 0.1.
Everything below maps to improving one or more of these. If you're ever asked "what should I optimize first?" — measure these three and fix whichever is worst.
Image Optimization: The Biggest Win
Images account for roughly 50% of page weight on the average website. This is where you get the most improvement for the least effort.
Use Modern Formats
Stop serving PNG and JPEG for photos. Use WebP or AVIF. The savings are dramatic — AVIF is typically 50% smaller than JPEG at equivalent quality. WebP is about 25-30% smaller.
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="Hero banner" width="1200" height="600"
loading="lazy" decoding="async">
</picture>
The <picture> element lets browsers pick the best format they support. Everyone gets the smallest file they can handle.
Always Set Width and Height
This one's embarrassingly simple, but it fixes CLS. When you specify width and height on an <img> tag, the browser reserves space before the image loads. No layout shift.
/* Combine with this CSS for responsive sizing */
img {
max-width: 100%;
height: auto;
}
Lazy Load Below-the-Fold Images
Native lazy loading is a single attribute:
<img src="photo.webp" loading="lazy" alt="Product shot">
The browser won't fetch it until it's near the viewport. But don't lazy load your LCP image — that hero banner at the top needs to load immediately. In fact, preload it:
<link rel="preload" as="image" href="hero.webp" type="image/webp">
Tip: Use fetchpriority="high" on your LCP image to tell the browser it's important. This attribute can improve LCP by 100-400ms in real-world tests. Pair it with a preload link for maximum effect.
Font Optimization: Death by a Thousand Requests
Custom fonts are one of those things that look innocent until you profile them. A single Google Fonts include can trigger 3-4 HTTP requests and block rendering for 200ms+.
Self-Host Your Fonts
Download the font files, convert to WOFF2 (the smallest format), and host them yourself. You eliminate the DNS lookup and connection to Google's CDN:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
font-style: normal;
}
That font-display: swap is critical. It tells the browser to show text in a system font immediately, then swap in the custom font when it loads. Without it, you get a flash of invisible text (FOIT) — users stare at blank space.
Subset Your Fonts
If you only use Latin characters, don't ship Cyrillic, Greek, and Vietnamese glyphs. Tools like glyphhanger or pyftsubset can strip unused characters. I've seen font files go from 250KB to 30KB after subsetting.
Limit Font Weights
Every weight is another file. Do you really need Regular, Medium, SemiBold, Bold, and ExtraBold? In my experience, two weights — Regular and Bold — cover 90% of designs. Use variable fonts if you need more flexibility; one file handles all weights.
JavaScript: Load Less, Load Later
JavaScript is the most expensive resource on the web. Not just in file size, but in parsing and execution time. A 200KB script takes far longer to process than a 200KB image.
Code Splitting
Don't ship your entire app in one bundle. Split by route at minimum:
// React example with lazy loading
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Users who never visit Settings never download that code. The savings compound as your app grows.
Defer Third-Party Scripts
Analytics, chat widgets, social media embeds — they all add up. Load them after your content:
<!-- Bad: blocks rendering --> <script src="https://analytics.example.com/tracker.js"></script> <!-- Good: loads after HTML parsing --> <script defer src="https://analytics.example.com/tracker.js"></script> <!-- Also good: loads in parallel, executes when ready --> <script async src="https://analytics.example.com/tracker.js"></script>
Use defer for scripts that need DOM access (they execute in order, after parsing). Use async for independent scripts like analytics (they execute as soon as they download, in any order).
Tip: Run npx bundlephobia <package-name> before adding any npm package. A "small utility library" can easily be 50KB gzipped. moment.js is 72KB. date-fns with tree-shaking is 3KB for most use cases. Know what you're shipping.
CSS: The Sneaky Blocker
CSS is render-blocking by default. The browser won't paint anything until it's parsed all your CSS. If your stylesheet is 300KB, that's a problem.
Inline Critical CSS
Extract the CSS needed for above-the-fold content and inline it directly in the <head>:
<head>
<style>
/* Only the CSS needed for initial render */
body { font-family: sans-serif; margin: 0; }
.hero { padding: 60px 20px; }
.nav { position: fixed; top: 0; width: 100%; }
</style>
<!-- Full stylesheet loads asynchronously -->
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
</head>
The browser renders immediately with the inlined CSS, then loads the full stylesheet in the background. Users see content faster.
Remove Unused CSS
Tools like PurgeCSS scan your HTML/JS and strip any CSS selectors that aren't used. I've seen projects where 80% of the CSS was dead code from frameworks like Bootstrap. Removing it cut the stylesheet from 180KB to 25KB.
Caching: Make Return Visits Instant
Once someone visits your site, subsequent visits should be near-instant. Caching makes this happen.
Set Aggressive Cache Headers
# Static assets with content hashes — cache forever Cache-Control: public, max-age=31536000, immutable # HTML pages — always revalidate Cache-Control: no-cache # API responses — cache briefly Cache-Control: public, max-age=60, s-maxage=300
The key insight: use content hashes in your filenames (app.a1b2c3.js). When the content changes, the filename changes. You can cache the old file forever because it'll never be requested again once the new version deploys.
Service Workers for Offline-First
For apps that need to work offline or on flaky connections, a service worker intercepts network requests and serves cached responses:
// Simple cache-first strategy
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open('v1').then(cache => cache.put(event.request, clone));
return response;
});
})
);
});
I won't pretend service workers are simple — they add complexity. But for the right use case (PWAs, documentation sites, dashboards), they're incredibly powerful.
Quick Wins Checklist
Before you dive into complex optimizations, knock out these freebies:
- Enable gzip/Brotli compression on your server. Brotli compresses 15-20% better than gzip.
- Use a CDN. Serve static assets from edge servers close to your users.
- Preconnect to required origins. If you load resources from third-party domains, tell the browser early:
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - Minify everything. HTML, CSS, and JS. Your bundler probably does this, but check.
- Avoid layout thrashing. Don't read DOM dimensions and write styles in a loop. Batch your reads, then batch your writes.
- Use
will-changesparingly. It hints to the browser that an element will animate, allowing GPU layer promotion. But overuse it and you'll eat memory.
Optimize Your HTML Templates
Convert your optimized HTML layouts to images for performance documentation, sharing, and visual regression testing.
Try HTML to PNG Converter →Measure, Don't Guess
The biggest performance mistake isn't a specific technique — it's optimizing without measuring. I've watched developers spend days shaving 10KB off a JavaScript bundle while ignoring the 2MB hero image loading synchronously above the fold.
Use Lighthouse for lab data. Use Chrome's CrUX dashboard or PageSpeed Insights for real-user data. Use the Performance tab in DevTools to trace exactly what's happening during load. Find the bottleneck, fix it, measure again.
Performance work isn't glamorous. It's not a new framework or a clever architecture. It's the boring stuff — right image formats, proper caching, loading scripts smartly, not shipping code you don't need. But it's the boring stuff that makes your site feel fast. And fast sites win.