⚡ Performance Optimization
A fast website isn't just a nice-to-have — it directly affects user retention, conversion rates, and SEO rankings. Studies show that a 1-second delay in load time can reduce conversions by 7% and increase bounce rate by 32%. This page covers the core techniques every frontend developer should know.
Core Web Vitals
Google's Core Web Vitals are the three metrics that measure real user experience. They directly influence Google Search rankings, so improving them matters for both users and visibility.
LCP
Largest Contentful Paint
Time until the largest visible element loads.
Good: under 2.5s
CLS
Cumulative Layout Shift
How much the page jumps around during load.
Good: under 0.1
INP
Interaction to Next Paint
How fast the page responds to clicks/taps.
Good: under 200ms
- Chrome DevTools → Lighthouse — run an audit in one click, scores all three
- DevTools → Performance tab — record and inspect frame-by-frame
- PageSpeed Insights — uses real-world data from Chrome users
- WebPageTest — detailed waterfall, filmstrip, comparison tests
The Critical Rendering Path
Understanding how browsers turn HTML into pixels is the foundation of performance work. Every optimization maps back to shortening one of these steps.
- Parse HTML → build the DOM tree. Stops whenever it hits a blocking resource.
- Parse CSS → build the CSSOM. Blocks rendering until complete.
- Combine DOM + CSSOM → build the Render Tree (only visible elements).
- Layout → calculate position and size of every element.
- Paint → fill in pixels (colors, images, shadows).
- Composite → combine layers and display to the screen.
By default, <script> and <link rel="stylesheet">
pause HTML parsing while they download and execute. The browser can't show anything
until they finish. Your job is to minimize, defer, or eliminate these blocks.
Script Loading: async, defer, type="module"
How you load JavaScript has a massive impact on page speed.
Never put scripts in <head> without a loading strategy.
<!-- ❌ Blocks HTML parsing — browser stops and waits -->
<head>
<script src="app.js"></script>
</head>
<!-- ✅ defer: downloads in parallel, runs after HTML is parsed
preserves execution order between scripts -->
<script defer src="app.js"></script>
<!-- ✅ async: downloads in parallel, runs immediately when ready
no guaranteed order — best for independent scripts like analytics -->
<script async src="analytics.js"></script>
<!-- ✅ type="module": always deferred, supports import/export -->
<script type="module" src="app.js"></script>
| Attribute | Downloads | Executes | Order preserved | Use for |
|---|---|---|---|---|
| (none) | Blocks parse | Immediately | Yes | Avoid |
defer |
Parallel | After HTML parsed | Yes | App scripts |
async |
Parallel | When ready | No | Analytics, ads |
type="module" |
Parallel | After HTML parsed | Yes | Modern apps |
Resource Hints
Resource hints let you tell the browser about resources it will need before it discovers them naturally, giving it a head start.
<head>
<!-- preload: download this ASAP — use for LCP image, hero font, critical CSS -->
<link rel="preload" as="image" href="hero.webp">
<link rel="preload" as="font" href="font.woff2" crossorigin>
<!-- prefetch: download when browser is idle — use for next-page resources -->
<link rel="prefetch" href="dashboard.js">
<!-- preconnect: open a connection early — use for CDNs and third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- dns-prefetch: cheaper than preconnect — resolves DNS early -->
<link rel="dns-prefetch" href="https://api.example.com">
</head>
| Hint | What it does | Use when |
|---|---|---|
preload |
Downloads the resource at high priority | LCP image, hero font, critical script |
prefetch |
Downloads at low priority when idle | Resources needed on the next page |
preconnect |
Opens TCP + TLS connection early | Any third-party origin (fonts, CDN, API) |
dns-prefetch |
Resolves DNS only (lighter than preconnect) | Origins you'll connect to later, not immediately |
Preloading too many resources defeats the purpose — everything becomes "high priority" which means nothing is. Preload only 2–3 truly critical above-the-fold resources.
CSS Performance
Avoid triggering expensive layout operations
Some CSS changes force the browser to recalculate layouts for the entire page (reflow). Others only require repainting pixels. The cheapest changes happen on the compositor thread and skip both.
| Cost | Operations | Examples |
|---|---|---|
| 🔴 High (reflow) | Change geometry | width, height, margin, padding, font-size, top, left |
| 🟡 Medium (repaint) | Change appearance only | color, background-color, border-color, box-shadow |
| 🟢 Cheap (composite only) | Move/fade layers | transform, opacity |
/* ❌ Triggers reflow on every frame — causes jank */
@keyframes slide-bad {
from { left: 0; }
to { left: 200px; }
}
/* ✅ Compositor-only — silky 60fps */
@keyframes slide-good {
from { transform: translateX(0); }
to { transform: translateX(200px); }
}
/* ❌ Animating width causes reflow — janky transitions */
.menu { transition: width 0.3s; }
/* ✅ Animate transform instead */
.menu { transition: transform 0.3s; }
will-change — promote an element to its own layer
/* Hints the browser to create a GPU layer before animation starts
so it doesn't have to do it on the first frame */
.modal {
will-change: transform, opacity;
}
/* Remove it after the animation — layers consume GPU memory */
.modal.is-hidden {
will-change: auto;
}
Each will-change layer consumes GPU memory. Only use it on elements
that actually animate, and only when you can measure a real improvement.
Overuse can make performance worse.
Critical CSS — inline above-the-fold styles
The browser can't render anything until all CSS is downloaded. For fast first
paints, inline the CSS needed for above-the-fold content directly in
<head> and load the rest asynchronously.
<head>
<!-- Critical CSS inlined — no network round-trip needed -->
<style>
body { margin: 0; font-family: sans-serif; }
.hero { height: 100dvh; display: flex; align-items: center; }
.nav { position: fixed; top: 0; width: 100%; }
</style>
<!-- Non-critical CSS loaded asynchronously -->
<link rel="stylesheet" href="styles.css" media="print"
onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="styles.css">
</noscript>
</head>
JavaScript Performance Patterns
Debounce — limit how often a function fires
Some events fire dozens of times per second — scroll, resize, keyup. Running heavy logic on every single event causes lag. Debouncing waits until the event has stopped firing for a set delay before running the function.
// Debounce: wait until the user stops typing for 300ms
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// Without debounce: search fires on every keystroke
input.addEventListener('input', searchAPI); // ❌ too many requests
// With debounce: search fires 300ms after the user stops typing
const debouncedSearch = debounce(searchAPI, 300);
input.addEventListener('input', debouncedSearch); // ✅
Throttle — cap the rate a function can fire
Throttling guarantees a function runs at most once every N milliseconds, no matter how often the event fires. Use it when you need regular updates (scroll position, mouse tracking) but not on every single frame.
// Throttle: run at most once every 100ms
function throttle(fn, limit) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
fn.apply(this, args);
}
};
}
// Update progress bar on scroll — throttled to 10 times/sec max
const throttledScroll = throttle(updateProgressBar, 100);
window.addEventListener('scroll', throttledScroll);
// Rule of thumb:
// Debounce → "after it stops" (search, resize, form validation)
// Throttle → "while it's happening, but slower" (scroll, mousemove)
Avoid layout thrashing
Layout thrashing happens when JavaScript alternates between reading and writing DOM geometry, forcing the browser to recalculate layout repeatedly inside the same frame.
// ❌ Layout thrashing — read/write/read/write alternating
elements.forEach(el => {
const width = el.offsetWidth; // READ → forces layout
el.style.width = width + 10 + 'px'; // WRITE → invalidates layout
const height = el.offsetHeight; // READ → forces layout again!
el.style.height = height + 10 + 'px';
});
// ✅ Batch reads first, then writes
const measurements = elements.map(el => ({
width: el.offsetWidth, // READ all
height: el.offsetHeight
}));
elements.forEach((el, i) => {
el.style.width = measurements[i].width + 10 + 'px'; // WRITE all
el.style.height = measurements[i].height + 10 + 'px';
});
requestAnimationFrame for visual updates
// ❌ setTimeout for animation — doesn't sync with the screen refresh
setTimeout(animate, 16); // might fire at the wrong moment
// ✅ requestAnimationFrame — syncs with the browser's repaint cycle
// runs at 60fps (or whatever the screen supports), pauses when tab is hidden
function animate(timestamp) {
// update positions using timestamp for smooth, time-based motion
element.style.transform = `translateX(${timestamp * 0.1 % 400}px)`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Memoization — cache expensive results
// ❌ Recalculates every time, even with the same input
function expensiveCalc(n) {
/* complex logic */
return result;
}
// ✅ Returns cached result for repeated inputs
function memoize(fn) {
const cache = new Map();
return function(n) {
if (cache.has(n)) return cache.get(n);
const result = fn(n);
cache.set(n, result);
return result;
};
}
const fastCalc = memoize(expensiveCalc);
fastCalc(42); // calculated
fastCalc(42); // returned from cache instantly
Event delegation — one listener instead of many
// ❌ One listener per item — slow for large lists, breaks on dynamic items
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleClick);
});
// ✅ One listener on the parent — handles all current AND future children
document.querySelector('.list').addEventListener('click', (e) => {
if (e.target.closest('.item')) {
handleClick(e);
}
});
Network and Asset Optimization
Minification and bundling
Minification removes whitespace, comments, and shortens variable names. Bundling combines many files into fewer requests. Modern build tools (Vite, webpack, esbuild) do both automatically.
/* Before minification — 312 bytes */
.navigation {
display: flex;
align-items: center;
background-color: #ffffff;
}
/* After minification — 60 bytes */
.navigation{display:flex;align-items:center;background-color:#fff}
HTTP caching
The browser can store assets locally and reuse them on repeat visits, eliminating network requests entirely.
/* Server response headers — set by your server/CDN config */
/* HTML: short cache — content changes frequently */
Cache-Control: no-cache, must-revalidate
/* CSS/JS with content hash in filename: cache forever
When code changes, the filename changes → new URL → new download */
Cache-Control: public, max-age=31536000, immutable
/* Images: cache for a week */
Cache-Control: public, max-age=604800
Build tools like Vite output files named app.3f8a2c1d.js
where the hash changes whenever the code changes.
This lets you serve those files with a permanent cache
while still getting instant updates when you deploy.
Tree shaking — eliminate dead code
// ❌ Imports entire library — all 70kb included in bundle
import _ from 'lodash';
_.debounce(fn, 300);
// ✅ Named import — bundler includes ONLY debounce (~2kb)
import { debounce } from 'lodash-es';
debounce(fn, 300);
// ✅ Even better — write it yourself (zero dependencies)
const debouncedFn = debounce(fn, 300); // your own implementation
Lazy loading routes and components
// ❌ Everything loads upfront — large initial bundle
import Dashboard from './Dashboard';
import Settings from './Settings';
import Profile from './Profile';
// ✅ Dynamic import — each route loads only when navigated to
const Dashboard = () => import('./Dashboard');
const Settings = () => import('./Settings');
const Profile = () => import('./Profile');
Font Optimization
Web fonts are a common performance culprit — they block text rendering until downloaded.
/* 1. Preconnect to the font origin */
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
/* 2. font-display: swap — show fallback font immediately,
swap in the web font when it arrives */
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: swap; /* prevents invisible text during load */
}
/* 3. Only load the weights and styles you actually use */
/* ❌ Loads all 9 weights */
@import url('https://fonts.googleapis.com/css2?family=Inter');
/* ✅ Loads only weight 400 and 700 */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700');
/* 4. Self-host fonts for the best performance — no third-party request */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-400.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
Images — Quick Checklist
See the Responsive Images page for full detail. Key points:
- Always set
widthandheightattributes to prevent layout shift - Serve AVIF → WebP → JPEG via
<picture> - Use
srcset+sizesto serve appropriately-sized images - Add
loading="lazy"to all below-the-fold images - Add
fetchpriority="high"to your LCP image (hero) - Compress images before uploading — tools: Squoosh, ImageOptim, Sharp
- Use SVG for icons and logos — infinitely scalable, tiny file size
Measuring and Diagnosing Performance
Lighthouse audit
// In Chrome DevTools:
// 1. Open DevTools (F12)
// 2. Go to "Lighthouse" tab
// 3. Select "Performance" category
// 4. Click "Analyze page load"
// Scores to aim for:
// Performance: 90+
// Accessibility: 90+
// Best Practices: 90+
// SEO: 90+
Performance tab — find the bottleneck
- Open DevTools → Performance tab
- Click the record button, interact with the page, stop recording
- Look for long tasks (red bars) — these block the main thread
- Check the Frames row — drops below 60fps cause visual jank
- Inspect the flame chart to find exactly which function is slow
Network tab — spot heavy resources
- Open DevTools → Network tab, reload the page
- Sort by Size — find unexpectedly large files
- Sort by Time — find slow requests
- Use the Waterfall column — long bars = slow, gaps = waiting
- Enable throttling (Fast 3G) to simulate mobile network conditions
Optimization Checklist
HTML
- All
<script>tags usedeferorasync - Hero image has
fetchpriority="high", all others haveloading="lazy" - LCP image is preloaded with
<link rel="preload"> preconnectadded for all third-party origins (fonts, CDN, APIs)- All images have
width,height, and meaningfulalt
CSS
- Animations use only
transformandopacity - Critical above-the-fold CSS is inlined or loaded with high priority
- Unused CSS is removed (PurgeCSS, manual audit)
- Font faces use
font-display: swap - Only the font weights/styles actually used are loaded
JavaScript
- Event listeners on frequently-firing events are debounced or throttled
- DOM reads and writes are batched separately (no layout thrashing)
- Animations use
requestAnimationFrame - Named imports used instead of full library imports (tree shaking)
- Large routes/components loaded with dynamic
import()
Assets
- Images served as AVIF/WebP with JPEG fallback
- All images compressed before upload
- CSS and JS minified (handled by build tool)
- Static assets cached with long
max-age+ content hashes in filenames - Lighthouse score above 90 for Performance