A practical guide to frontend performance
A slow site costs you users and conversions before they ever see your content. Studies have put a number on it for years: every extra second of load time drives people away, and the bounce rate climbs fast past the three-second mark. The good news is that most performance wins come from a small, well-understood set of techniques applied consistently. You don’t need exotic tricks — you need to send less, send it smarter, and stop blocking the browser from doing its job.
This is the playbook I keep coming back to, organized roughly around the metrics Google actually measures.
Start with the metrics
You can’t improve what you don’t measure, and intuition is a terrible profiler. Core Web Vitals give you a shared, user-centric vocabulary for “fast”:
- LCP (Largest Contentful Paint) — how long until the main content renders. Aim for under 2.5s. This is usually your hero image, headline, or a big block of text.
- CLS (Cumulative Layout Shift) — how much the page jumps around as it loads. Aim for under 0.1. Nothing erodes trust faster than a button that moves the moment you reach for it.
- INP (Interaction to Next Paint) — how snappy it feels when you tap, click, or type. Aim for under 200ms. This replaced FID as the responsiveness metric.
A few more worth watching: FCP (First Contentful Paint, the first pixel of content), TTFB (Time to First Byte, keep it under ~1.3s), and TTI (Time to Interactive, when the page actually responds to input).
Measure with PageSpeed Insights, Lighthouse, and WebPageTest for lab data, and add real-user monitoring (RUM) so you see what actual visitors experience on real devices and networks. You can also collect the vitals yourself in the browser:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.startTime, entry.value ?? entry.duration);
}
}).observe({ type: "largest-contentful-paint", buffered: true });
Get a baseline before you touch anything. Optimization without measurement is just guessing — and you’ll often find the bottleneck isn’t where you assumed.
Send fewer bytes
The cheapest byte is the one you never send. A few layers stack here:
Minify everything. Strip comments, whitespace, and dead code from HTML, CSS, and JavaScript. Build tools do this automatically — Terser for JS, CSSNano or CleanCSS for CSS — so make sure it’s actually on in production.
Compress on the wire. Enable Brotli (or at least Gzip) on your server or CDN. For text assets like JS, CSS, and HTML this routinely cuts transfer size by 60–80% for almost no effort.
Tree-shake and drop dead code. Import only what you use. The single most common bloat I find is a giant utility or date library pulled in for one helper:
// Ships the whole library
import _ from "lodash";
_.debounce(fn, 200);
// Ships one function
import debounce from "lodash/debounce";
debounce(fn, 200);
Prune your CSS. Frameworks and old features leave behind rules nothing uses. Tools like PurgeCSS and coverage reports in DevTools show you what’s dead so you can delete it.
Keep an eye on total page weight. Under ~1.5 MB is a sane ceiling; a few hundred KB is a great target. If you ship a bundle, run a bundle analyzer regularly so regressions don’t sneak in over time.
Make fewer requests
Every request carries connection and latency overhead, especially on mobile networks. Reduce the count:
- Combine files where it makes sense, and use SVG sprites or inline tiny icons instead of dozens of separate image requests.
- Use a CDN so static assets are served from a node geographically close to the user. This cuts latency and helps your TTFB at the same time.
- Avoid iframes where you can — they spin up a whole extra document, block the main page’s load, and hurt both performance and SEO. Reach for an API or a component instead.
Don’t block the render
CSS and JavaScript in the <head> are render-blocking by default — the browser
won’t paint until it has dealt with them. The fix is to prioritize what’s
critical and get everything else out of the critical path.
Inline critical CSS. Put the styles needed for above-the-fold content
directly in a <style> tag in the head, then load the rest asynchronously:
<link rel="preload" href="/styles.css" as="style"
onload="this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="/styles.css" /></noscript>
Defer or async your scripts. Use defer for scripts that touch the DOM (they
run in order, after parsing); use async for independent third-party scripts
like analytics that don’t depend on anything:
<script src="/app.js" defer></script>
<script src="/analytics.js" async></script>
Preload and preconnect the things you know you’ll need early — fonts, the hero image, a critical API origin:
<link rel="preload" href="/fonts/inter.woff2" as="font"
type="font/woff2" crossorigin />
<link rel="preconnect" href="https://api.example.com" />
Optimize images — usually the heaviest thing on the page
Images dominate most pages, which makes them the biggest lever for LCP. Several things compound here:
Use modern formats. WebP is roughly 25–35% smaller than JPEG; AVIF can be ~50% smaller than JPEG, with browser support now solid. Use SVG for icons and logos — it’s tiny and scales perfectly. Compress photos to around 80–85% quality; the difference is invisible and the savings are large.
Serve the right size for the screen. Don’t ship a 1600px image to a 400px phone. Let the browser choose:
<img
src="/photo-800.webp"
srcset="/photo-400.webp 400w, /photo-800.webp 800w, /photo-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 600px"
loading="lazy"
decoding="async"
width="800"
height="600"
alt="A descriptive caption"
/>
Lazy-load below the fold. loading="lazy" defers offscreen images until the
user scrolls near them — great for long pages, galleries, and infinite scroll.
The one exception: never lazy-load your LCP image. Lazy-loading the hero is a
classic self-inflicted regression; if anything, preload it.
Always set width and height. Those attributes let the browser reserve the
exact space before the image loads, which is the single biggest cause of layout
shift removed.
Stop layout shift at the source
CLS is almost always content arriving and pushing other content around. Three habits eliminate most of it:
- Reserve space for everything that loads late — images (dimensions),
videos, ads, embeds (set a
min-height), and dynamic banners. - Don’t inject content above existing content. A cookie bar or notice that shoves the page down after paint is a guaranteed shift. Reserve its slot or overlay it.
- Handle fonts deliberately.
font-display: swapshows text immediately in a fallback, but a big metric mismatch between fallback and webfont causes a shift when it swaps. Match the fallback’s metrics, or useoptionalso the swap only happens if the font is already cached.
Free the main thread
JavaScript isn’t just download cost — the browser has to parse, compile, and execute it, all on the main thread. That’s what makes a page feel janky to interact with, and it’s where INP and TTI live.
Code-split and lazy-load. Don’t ship one giant bundle. Split by route and load heavy components only when they’re needed:
const Chart = lazy(() => import("./Chart"));
Break up long tasks. Any task over ~50ms blocks input. Chunk heavy loops, and schedule non-urgent work so the browser can stay responsive:
function processInChunks(items, handle) {
function run(deadline) {
while (items.length && deadline.timeRemaining() > 0) {
handle(items.shift());
}
if (items.length) requestIdleCallback(run);
}
requestIdleCallback(run);
}
Debounce and throttle frequent events like scroll, resize, and input so your handlers fire a sensible number of times instead of hundreds per second.
Offload heavy computation to a Web Worker. Parsing big payloads, image processing, or expensive calculations don’t belong on the main thread — move them off so the UI keeps responding while they run.
Cache aggressively
Repeat visits should be nearly free. Set long Cache-Control lifetimes on
fingerprinted static assets (app.4f3a.js), so the browser reuses them instead of
re-downloading. A service worker can cache the app shell and assets for
instant repeat loads and offline support. And on the data side, cache API
responses where freshness allows — the fastest request is the one you don’t make.
Build a habit, not a one-off
Performance regresses quietly: a new dependency here, an unoptimized image there, and six months later the site is sluggish again. The fix is to make it part of the workflow — set a performance budget, run Lighthouse in CI so a regression fails the build, and watch your RUM dashboard for real-world drift.
The takeaway
Fast frontends come from doing less and doing it later: fewer bytes, fewer requests, smaller images, deferred scripts, a main thread you don’t hog, and a cache that does the work twice. None of it is a single heroic fix. It’s a hundred small, measurable decisions that compound — so measure first, apply these consistently, and measure again.