Achieve sub-2-second load times and pass Core Web Vitals. Covers image optimization, lazy loading, code splitting, CDN strategy, and Lighthouse auditing.
Web performance optimization is a continuous discipline that directly impacts user engagement, conversion, and system scalability. This page guides engineers through the essential practices to measure, diagnose, and improve web performance, with a focus on Core Web Vitals as the foundation and beyond. Use this guide when shipping a new web application, addressing user-reported slow loading, preparing for a major release, or tuning an existing site under load.
Core Web Vitals—Largest Contentful Paint (LCP), First Input Delay (FID), and Cumulative Layout Shift (CLS)—define the user experience of a web page. To measure them reliably:
web-vitals.js) in the browser to collect real-user metrics (RUM) and lab metrics.PerformanceObserver with the entryTypes paint, first-input, and layout-shift.import { getCLS, getFID, getLCP } from 'web-vitals';
getLCP((metric) => {
console.log('LCP:', metric.value);
// Send metric to analytics backend
sendToAnalytics('LCP', metric.value);
});
getFID((metric) => {
console.log('FID:', metric.value);
sendToAnalytics('FID', metric.value);
});
getCLS((metric) => {
console.log('CLS:', metric.value);
sendToAnalytics('CLS', metric.value);
});
npx puppeteer-core --headless=new --timeout=60000 \
--url=https://example.com \
--output=performance.json \
--lcp \
--fid \
--cls \
--throttling=Slow 3G
Each metric has a target threshold:
When any metric fails, use Chrome DevTools to inspect the Performance tab and Lighthouse report for granular insights. The "First Contentful Paint" and "First Meaningful Paint" are distinct: FCP captures the first rendered content, while FMP marks when the main content is rendered.
LCP is the most important metric for perceived loading speed. It is triggered when the largest visible element (typically hero image, heading, or article block) becomes visible.
<link rel="preload"> for key assets like fonts, hero images, and above-the-fold CSS.<link rel="preload" as="image" href="/hero.jpg" type="image/jpeg" />
<link rel="preload" as="font" href="/fonts/inter.woff2" type="font/woff2" crossorigin />
<link rel="preload" as="style" href="/styles/main.css" />
font-display: swap.@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
}
:method: GET
:path: /
:scheme: https
:authority: example.com
:status: 200
// Pushed resources
:method: GET
:path: /styles/main.css
:scheme: https
:authority: example.com
:status: 200
// Pushed CSS file
.br) for static assets, and gzip for older clients.
gzip on;
gzip_vary on;
gzip_types text/plain text/css text/xml application/xml application/xhtml+xml text/javascript application/javascript;
gzip_comp_level 6;
gzip_min_length 1024;
<head>.<style>
/* Critical CSS for above-the-fold content */
.hero { background: #000; color: #fff; }
.hero h1 { font-size: 3rem; }
.nav { background: #333; }
</style>
async and defer.<script src="/js/app.js" defer></script>
<link rel="stylesheet" href="/css/deferred.css" media="print" onload="this.media='all'">
srcset and sizes.<picture>
<source srcset="/hero-480w.webp 480w, /hero-800w.webp 800w, /hero-1200w.webp 1200w"
sizes="(max-width: 600px) 480px, (max-width: 1000px) 800px, 1200px"
type="image/webp" />
<img src="/hero-1200w.jpg" alt="Hero image" width="1200" height="600" />
</picture>
loading="lazy" and IntersectionObserver fallback.<img src="/thumbnail.jpg" loading="lazy" width="800" height="600" alt="Lazy-loaded image" />
FID measures interactivity. A delay >100ms between a user’s tap/click and the response feels sluggish.
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
optimization: {
splitChunks: {
chunks: 'all',
},
runtimeChunk: 'single',
},
plugins: [
new HtmlWebpackPlugin({
template: './index.html',
}),
],
};
react, lodash), and route-specific chunks.defer for scripts that don’t block rendering.<script src="/js/vendor.js" defer></script>
<script src="/js/app.js" defer></script>
document.addEventListener('DOMContentLoaded', () => {
// Initialize app
initApp();
});
// Or use async for independent scripts
<script src="/js/analytics.js" async></script>
requestIdleCallback or setTimeout.function processLargeDataset(data) {
const startTime = performance.now();
function processChunk() {
let startTimeChunk = performance.now();
const chunkSize = 1000;
for (let i = 0; i < chunkSize; i++) {
// Process 1000 items
processItem(data[i]);
}
if (data.length > 0) {
requestIdleCallback(processChunk, { timeout: 100 });
} else {
console.log(`Processing completed in ${performance.now() - startTime}ms`);
}
}
requestIdleCallback(processChunk, { timeout: 100 });
}
performance.measure() to track task duration.performance.mark('start-processing');
processLargeDataset(data);
performance.mark('end-processing');
performance.measure('processing-time', 'start-processing', 'end-processing');
CLS measures visual stability. A layout shift occurs when elements move unexpectedly after load.
width and height on <img>, <iframe>, and <video> elements.<img src="/hero.jpg" width="800" height="600" alt="Hero" loading="lazy" />
<iframe src="/video.html" width="640" height="360" frameborder="0"></iframe>
aspect-ratio CSS to preserve layout during image loading..image-container {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
<div class="hero-placeholder" style="height: 400px; background: #ccc;">
<h1>Loading…</h1>
<p>Content will appear shortly.</p>
</div>
<script>
fetch('/api/hero-content')
.then(res => res.json())
.then(data => {
const container = document.querySelector('.hero-placeholder');
container.innerHTML = `<h1>${data.title}</h1><p>${data.description}</p>`;
container.style.height = 'auto';
});
</script>
requestAnimationFrame.function updateLayout() {
// Update multiple elements
element1.style.transform = 'translateX(100px)';
element2.style.opacity = 0.5;
element3.style.width = '300px';
}
// Batch updates
requestAnimationFrame(updateLayout);
transform and opacity for animations, as they are GPU-accelerated.font-display: swap and preload fonts.@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
}
const fontLoader = new FontFaceObserver('Inter');
fontLoader.load().then(() => {
document.documentElement.classList.add('fonts-loaded');
});
fetch or navigator.sendBeacon.function sendToAnalytics(metricName, value) {
const payload = {
metric: metricName,
value: value,
url: window.location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
};
navigator.sendBeacon('/api/performance', JSON.stringify(payload));
}
# .github/workflows/lighthouse.yml
name: Lighthouse
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- run: npm run build
- run: npx lighthouse-ci --config=lighthouse-ci.config.js
// lighthouse-ci.config.js
module.exports = {
ci: {
url: [
'https://example.com/',
'https://example.com/blog',
'https://example.com/about'
],
collect: {
numberOfRuns: 3,
settings: {
formFactor: 'desktop',
throttling: {
method: 'simulate',
cpuSlowdownMultiplier: 4,
requestLatencyMs: 150,
downloadThroughputKbps: 1000,
uploadThroughputKbps: 750,
},
emulatedFormFactor: 'desktop',
},
},
upload: {
target: 'temporary-public-storage',
},
assert: {
assertions: {
'categories:performance': ['>=90'],
'categories:accessibility': ['>=90'],
'categories:seo': ['>=90'],
'metrics:largest-contentful-paint': ['<=2500'],
'metrics:interactive': ['<=1000'],
},
},
},
};
performance.measure and Chrome DevTools CPU profile to identify bottlenecks.width/height, aspect-ratio, and font-display.loading="lazy" is combined with srcset and sizes.font-display: swap and preload fonts.object-fit: cover and background-size: cover with background-position.Silent failures:
async scripts that don’t load due to missing async attribute or incorrect src paths.preload hints that are ignored due to missing as or type.defer scripts that run too early, before DOM is ready.Always measure performance after every deployment, and treat Core Web Vitals as production SLIs.
This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy