Page speed directly impacts user engagement and search rankings. Google’s Core Web Vitals measure real-world performance, and pages loading in under 2 seconds see significantly higher conversion rates. This guide covers the highest-impact optimization techniques: lazy loading, code splitting, image optimization, caching strategies, and resource hints. Each technique includes implementation code you can apply immediately.
Measuring Performance Before Optimizing
Before changing anything, establish a baseline. Lighthouse, WebPageTest, and the Performance API give you concrete numbers to improve against.
// Measure key performance metrics in the browser
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.startTime.toFixed(0)}ms`);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
observer.observe({ type: 'first-input', buffered: true });
observer.observe({ type: 'layout-shift', buffered: true });
// Custom timing markers
performance.mark('app-init-start');
// ... initialization code ...
performance.mark('app-init-end');
performance.measure('App Initialization', 'app-init-start', 'app-init-end');
const measure = performance.getEntriesByName('App Initialization')[0];
console.log(`App init took ${measure.duration.toFixed(0)}ms`);
The three Core Web Vitals to focus on are Largest Contentful Paint (LCP) under 2.5 seconds, Interaction to Next Paint (INP) under 200 milliseconds, and Cumulative Layout Shift (CLS) under 0.1. Every optimization in this guide targets one or more of these metrics.
Image Optimization and Lazy Loading
Images are typically the heaviest resources on a page. Modern formats, responsive sizing, and lazy loading can cut image payload by 60-80%.
<!-- Responsive images with modern formats -->
<picture>
<source srcset="hero-800.avif 800w, hero-1200.avif 1200w, hero-1600.avif 1600w"
sizes="(max-width: 800px) 100vw, (max-width: 1200px) 80vw, 1200px"
type="image/avif" />
<source srcset="hero-800.webp 800w, hero-1200.webp 1200w, hero-1600.webp 1600w"
sizes="(max-width: 800px) 100vw, (max-width: 1200px) 80vw, 1200px"
type="image/webp" />
<img src="hero-1200.jpg"
alt="Hero banner"
width="1200" height="600"
fetchpriority="high"
decoding="async" />
</picture>
<!-- Below-fold images: native lazy loading -->
<img src="feature.webp"
alt="Feature illustration"
width="600" height="400"
loading="lazy"
decoding="async" />
For dynamic image loading with an Intersection Observer, you get fine-grained control over when images start downloading.
// Advanced lazy loading with blur-up placeholder
class LazyImageLoader {
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.observer.unobserve(entry.target);
}
});
},
{ rootMargin: '200px' } // Start loading 200px before visible
);
document.querySelectorAll('img[data-src]').forEach(img => {
this.observer.observe(img);
});
}
loadImage(img) {
const src = img.dataset.src;
const srcset = img.dataset.srcset;
img.src = src;
if (srcset) img.srcset = srcset;
img.onload = () => img.classList.add('loaded');
}
}
new LazyImageLoader();
Code Splitting and Bundle Optimization
Shipping a single JavaScript bundle forces users to download code they may never execute. Code splitting breaks your bundle into smaller chunks loaded on demand. Here is how to implement it in different frameworks.
// React: lazy loading components
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<div className="skeleton-page" />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
// Dynamic imports for heavy libraries
async function generateChart(data) {
const { Chart } = await import('chart.js/auto');
new Chart(document.getElementById('chart'), {
type: 'line',
data: {
labels: data.map(d => d.label),
datasets: [{ data: data.map(d => d.value) }],
},
});
}
Webpack and Vite both support magic comments for naming chunks and controlling prefetch behavior.
// Named chunks with prefetch hints
const AdminPanel = lazy(() =>
import(/* webpackChunkName: "admin" */ './pages/AdminPanel')
);
// Prefetch: download during idle time (likely needed later)
const UserProfile = lazy(() =>
import(/* webpackPrefetch: true */ './pages/UserProfile')
);
// Preload: download immediately (needed very soon)
const Checkout = lazy(() =>
import(/* webpackPreload: true */ './pages/Checkout')
);
Resource Hints and Critical Rendering Path
Resource hints tell the browser what to prioritize. Preconnecting to origins, preloading critical assets, and deferring non-essential scripts shaves hundreds of milliseconds off load times.
<head>
<!-- Preconnect to required origins -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font"
type="font/woff2" crossorigin />
<link rel="preload" href="/css/critical.css" as="style" />
<!-- Inline critical CSS -->
<style>
*,*::before,*::after{box-sizing:border-box;margin:0}
body{font-family:Inter,system-ui,sans-serif;line-height:1.6}
.hero{min-height:60vh;display:flex;align-items:center;justify-content:center}
.skeleton{background:linear-gradient(90deg,#f0f0f0 25%,#e0e0e0 50%,#f0f0f0 75%);
background-size:200% 100%;animation:shimmer 1.5s infinite}
@keyframes shimmer{to{background-position:-200% 0}}
</style>
<!-- Load full CSS asynchronously -->
<link rel="preload" href="/css/main.css" as="style"
onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="/css/main.css" /></noscript>
<!-- Defer non-critical JS -->
<script src="/js/app.js" defer></script>
<script src="/js/analytics.js" defer></script>
</head>
Caching Strategies for Repeat Visits
Proper caching ensures returning visitors experience near-instant loads. Combine HTTP cache headers with a Service Worker for offline-capable performance.
// Express.js cache headers
app.use('/assets', express.static('public/assets', {
maxAge: '1y', // Immutable assets (hashed filenames)
immutable: true,
}));
app.use('/api', (req, res, next) => {
res.set('Cache-Control', 'no-store'); // Never cache API responses
next();
});
// HTML pages: short cache with revalidation
app.get('*', (req, res, next) => {
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=86400');
next();
});
A Service Worker with a stale-while-revalidate strategy provides instant loads while updating content in the background.
// service-worker.js
const CACHE_NAME = 'app-cache-v1';
const PRECACHE_URLS = ['/', '/css/main.css', '/js/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
});
return cached || fetchPromise;
})
);
});
Combining these techniques compounds their impact. Optimized images reduce payload, code splitting reduces initial JavaScript, resource hints eliminate connection latency, and caching eliminates repeat downloads. A site that loads in 4 seconds can realistically hit sub-2-second loads by applying these strategies systematically, starting with the changes that affect the largest resources first.