Why performance is a feature
Performance is not a polish step at the end of a project. It decides whether users see your content at all. A page that takes four seconds to show its main content loses a large share of visitors before it is usable, and slow interactions make an app feel broken even when it works.
The good news is that performance is measurable and mostly systematic. A small number of levers — load less, load later, do less work on the main thread — account for the vast majority of the wins. This guide covers the metrics, the tools and the techniques that matter most.
Core Web Vitals
Three metrics, collectively called Core Web Vitals, describe the user experience:
- Largest Contentful Paint (LCP) — when the largest visible element finishes rendering. Good is under 2.5 seconds.
- Interaction to Next Paint (INP) — the latency between a user interaction and the next visual update. Good is under 200 milliseconds.
- Cumulative Layout Shift (CLS) — how much visible content moves unexpectedly. Good is under 0.1.
Supporting metrics include Time to First Byte (TTFB) for server latency and First Contentful Paint (FCP) for when anything first appears. Together they tell you whether a page is fast, responsive and stable.
Measuring
You need both field data and lab data.
Field data comes from real users and reflects the true range of devices and networks. It is what you optimise for. Lab data from Lighthouse or DevTools is reproducible, which makes it better for diagnosing a specific problem.
The browser exposes real timings through the Performance API.
// observe.js
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.startTime);
}
}).observe({ type: "largest-contentful-paint", buffered: true });
Libraries like web-vitals wrap these observers and report to your analytics, which gives you field data without building the plumbing yourself.
Loading less
The single biggest lever is shipping less. JavaScript is the most expensive resource because it must be downloaded, parsed and executed, and it competes with rendering for the main thread.
- Remove unused dependencies and prefer smaller alternatives.
- Import only the functions you use from a library.
- Split routes and heavy features with dynamic
import(). - Render on the server where possible and ship only the interactive parts.
- Compress and serve modern formats such as Brotli and AVIF.
- Set a bundle budget and fail CI when it grows.
A bundle analyser shows exactly what is in your build, which is usually surprising the first time you look.
Loading later
Not everything is needed for the first paint. Defer the rest.
<!-- deferred.js -->
<script src="app.js" defer></script>
<script src="analytics.js" async></script>
<img src="hero.avif" fetchpriority="high" alt="Hero" />
<img src="thumb.avif" loading="lazy" decoding="async" width="400" height="300" alt="" />
defer runs scripts after parsing, async runs them as soon as they download, fetchpriority="high" promotes the LCP image, and loading="lazy" defers offscreen images. Dynamic import() does the same for JavaScript modules.
Rendering and stability
Two rendering issues dominate CLS and perceived speed.
Reserve space for images, iframes, ads and embeds so they cannot push content around. Set width and height, or use aspect-ratio in CSS. Use a font strategy that avoids a late swap, such as font-display: swap with a matching fallback or self-hosted fonts.
Keep the main thread free. Long tasks block interaction and inflate INP. Break up expensive work, move heavy computation to a Web Worker, and avoid layout thrashing by batching DOM reads and writes.
Caching
Caching turns repeat visits into instant loads. Fingerprint assets with content hashes and serve them with a long Cache-Control max-age, since a changed file gets a new name. Serve HTML with a short cache and revalidate. For offline and repeat-visit speed, a service worker can cache the app shell and assets, as covered in the PWA guide.
Performance budgets
A budget turns performance into a constraint rather than a hope.
{
"budgets": [
{ "path": "/*", "resourceSizes": [{ "resourceType": "script", "budget": 170 }] }
]
}
Pick a realistic limit for JavaScript, images and LCP, then enforce it in CI. When a pull request exceeds the budget, the build fails and the author decides whether the trade-off is worth it. This is how teams keep performance from eroding over time.
Best practices
- Measure before optimising, and use both field and lab data.
- Ship less JavaScript before micro-optimising anything else.
- Lazy-load below-the-fold images and split heavy routes.
- Set explicit dimensions on media to protect CLS.
- Use
fetchpriority,deferandasyncdeliberately. - Fingerprint assets and cache them for a long time.
- Set and enforce a bundle budget in CI.
Common mistakes
- Optimising the wrong thing without measuring.
- Shipping a huge client bundle for content that could be static.
- Eagerly loading every image and script.
- Omitting image dimensions and causing layout shift.
- Blocking the main thread with long synchronous tasks.
- Treating performance as a one-off project instead of a continuous budget.
Where to go next
Performance is a discipline, not a checklist. Use Vite to split and analyse your bundle, cut JavaScript weight with the patterns in the React guide, add offline caching with PWA, and keep the main thread free with solid JavaScript. Then measure your own site with field data and pick the one change with the biggest impact.