Front-end performance: how to measure it, and what to actually fix
Core Web Vitals explained (LCP, INP, CLS), lab vs field measurement, the tools that give real answers, how to read a performance trace, and the fix toolbox: code splitting, breaking up long tasks, debounce and throttle, fewer re-renders, images and fonts.
Performance work goes wrong when it starts from a guess. "It feels slow, let me add useMemo everywhere." The order that works is: measure, find the specific bottleneck in a trace, fix that one thing, measure again. This post covers the metrics worth tracking, how to get trustworthy numbers, how to read a trace, and the concrete fixes. The caching layer is covered separately in the CDN and caching post.
The metrics that matter: Core Web Vitals#
Google's Core Web Vitals are three field metrics measured at the 75th percentile of your real users. They also feed search ranking, so they are the ones with teeth.
| Metric | Measures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Time until the biggest above-the-fold element renders | ≤ 2.5s | 2.5-4s | > 4s |
| INP (Interaction to Next Paint) | Worst-case latency from a click/tap/keypress to the next frame | ≤ 200ms | 200-500ms | > 500ms |
| CLS (Cumulative Layout Shift) | How much visible content jumps around unexpectedly | ≤ 0.1 | 0.1-0.25 | > 0.25 |
What drives each one
- LCP is time to first byte, plus render-blocking CSS and JS, plus the LCP resource's own load time, plus any client-side render delay. A React app that renders the hero only after a data fetch has an LCP problem that no image optimisation will fix.
- INP (replaced FID in March 2024) is about main-thread contention. Every interaction competes with your JavaScript. Big synchronous re-renders, unbatched state updates, heavy event handlers and third-party scripts are the usual causes.
- CLS comes from images and iframes without width/height, content injected above existing content (banners, ads, late-loading components), and web fonts swapping in at a different size.
Supporting metrics (diagnostics, not goals)
- TTFB is server and network time before anything renders. A high TTFB caps your LCP.
- FCP is the first pixel of content. The gap between FCP and LCP tells you whether the problem is early (blocking resources) or late (the main element specifically).
- TBT, Total Blocking Time, is the lab proxy for INP: summed main-thread blocking above 50ms during load.
- Long Animation Frames (LoAF) is the newer API that attributes slow frames to specific scripts. It is the best signal for chasing INP.
Lab vs field: you need both#
| Lab (synthetic) | Field (RUM / CrUX) | |
|---|---|---|
| Source | Lighthouse, WebPageTest, DevTools; one machine, throttled | Real users, real devices/networks |
| Good for | Reproducible diagnostics, pre-deploy checks, waterfalls, traces | Ground truth, ranking, spotting device/geo-specific issues |
| Weak at | Representing your actual user base | Telling you *why*; it is aggregate and delayed |
How to measure: the toolbox#
1. The web-vitals library, piped to your analytics (field)
This is the single highest-value thing to add. ~2 KB, gives you real INP and LCP attribution:
import { onLCP, onINP, onCLS, onTTFB } from "web-vitals/attribution"
function report(metric) {
// metric.attribution tells you WHICH element / event / script caused it
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
id: metric.id,
attribution: metric.attribution,
path: location.pathname,
})
navigator.sendBeacon("/rum", body)
}
onLCP(report)
onINP(report) // fires on page hide, so you capture the worst interaction
onCLS(report)
onTTFB(report)Send it to GA4, a custom endpoint, or a provider. Chart p75per route, per metric. That dashboard is your scoreboard.
2. Chrome DevTools Performance panel (lab)
- CPU throttling 4-6x and network throttling on, every time. Your machine is not the user's.
- Record a load, or record while doing the slow interaction.
- Read the main thread flame chart for long tasks (the red-cornered blocks > 50ms).
- The Performance Insights panel calls out render-blocking requests, LCP breakdown, and layout shifts with the culprit node.
- Coverage tab: how much of each JS/CSS file is unused on this page.
3. Lighthouse / PageSpeed Insights
PSI shows lab (Lighthouse) and field (CrUX, last 28 days) side by side. Use the field section as the verdict and the opportunities/diagnostics as leads. Run Lighthouse in CI for regression gates, not for the vanity 100.
4. WebPageTest
The filmstrip and request waterfall from real locations and devices. Best tool for "what is happening between 0 and 2 seconds" and for seeing connection setup, blocking chains and third-party cost.
5. React DevTools Profiler and custom marks
performance.mark("cart:open:start")
// (open the cart drawer, fetch, render)
performance.mark("cart:open:end")
performance.measure("cart:open", "cart:open:start", "cart:open:end")
new PerformanceObserver((list) => {
for (const e of list.getEntries()) console.log(e.name, e.duration)
}).observe({ type: "measure", buffered: true })The React Profiler's flame/ranked chart shows which components rendered and how long each commit took. "Highlight updates when components render" in the settings is the fastest way to see needless re-renders.
6. Bundle analysis
# Vite
npx vite-bundle-visualizer
# or add rollup-plugin-visualizer to the build and open stats.htmlLook for: duplicated dependencies, a date/i18n/icon library imported whole, moment.js, lodash without per-method imports, big polyfills you no longer need.
Reading a trace: what to look for#
- Render-blocking resources in
<head>: a synchronous<script>, large non-critical CSS, blocking font requests. These delay the LCP. - Long tasks (> 50ms) on the main thread. Each one is a window where the page cannot respond to input. This is your INP problem, made visible.
- Forced synchronous layout, or layout thrash: a loop that reads
offsetHeight, writes a style, then reads again. DevTools flags these in purple with a warning triangle. - Excessive re-renders: the same components committing on every keystroke or scroll frame.
- Script evaluation cost: long yellow "Evaluate Script" blocks mean too much JS is parsing and executing up front.
- Image decode and resize: large images being downscaled by the browser; late-loading images causing shift.
- Font swap: text invisible (FOIT) or reflowing (FOUT) partway through load.
The fix toolbox#
Ship less JavaScript
- Route-based code splitting with
React.lazyandSuspenseper route, so the first page does not pay for the whole app. - Dynamic
import()for heavy, rarely-used chunks (a chart library, a rich text editor, a PDF viewer). - Swap heavy deps:
date-fnsorTemporalover moment, per-methodlodash-esimports, a 2 KB router instead of a 20 KB one when you use 5% of it. - Defer third-party scripts: load analytics, chat and A/B tools with
asyncordefer, or on interaction or idle. They are often the biggest INP offender.
Break up long tasks
// Yield to the browser so input can be handled between chunks of work
async function processInChunks(items, handle) {
for (let i = 0; i < items.length; i++) {
handle(items[i])
if (i % 50 === 0) await yieldToMain()
}
}
const yieldToMain = () =>
"scheduler" in window && "yield" in scheduler
? scheduler.yield()
: new Promise((r) => setTimeout(r, 0))- Move heavy compute (parsing, crypto, image work, diffing) to a Web Worker.
- In React, wrap non-urgent state updates in
startTransition, and derive expensive filtered/sorted views throughuseDeferredValueso typing stays responsive.
Debounce and throttle
Different tools for different jobs:
| Debounce | Throttle | |
|---|---|---|
| Fires | Once, after activity has stopped for N ms | At most once per N ms during activity |
| Use for | Search-as-you-type, autosave, recomputing layout on resize, validation | Scroll handlers, mousemove, drag, firing analytics on progress |
| If you use the wrong one | Throttled search = a request per keystroke burst | Debounced scroll = handler never runs until scrolling stops |
function debounce(fn, wait) {
let t
const debounced = (...args) => {
clearTimeout(t)
t = setTimeout(() => fn(...args), wait)
}
debounced.cancel = () => clearTimeout(t)
return debounced
}
function throttle(fn, wait) {
let last = 0
let timer
return (...args) => {
const now = Date.now()
const remaining = wait - (now - last)
if (remaining <= 0) {
clearTimeout(timer)
last = now
fn(...args)
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now()
timer = undefined
fn(...args)
}, remaining)
}
}
}In React, keep the timer stable and clean it up:
function useDebouncedValue(value, wait = 300) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const t = setTimeout(() => setDebounced(value), wait)
return () => clearTimeout(t) // cancel on the next keystroke / unmount
}, [value, wait])
return debounced
}
// search box
const [query, setQuery] = useState("")
const debouncedQuery = useDebouncedValue(query, 300)
const { data } = useQuery({
queryKey: ["search", debouncedQuery],
queryFn: () => search(debouncedQuery),
enabled: debouncedQuery.length > 1,
})- For purely visual updates tied to scroll/mousemove, throttle with
requestAnimationFrameinstead of a timer: one update per frame, never more. - Add
{ passive: true }to scroll/touch listeners so they never block scrolling. - Reach for a tested
lodash-es/debounceif you need leading/trailing edges andmaxWait.
Cut re-renders
- Measure first with the Profiler; do not
memoblind. React.memoa component only when its parent re-renders often and its props are usually stable.- Stabilise callbacks/objects passed as props with
useCallback/useMemo; unstable props defeatmemo. - Split large contexts. A single "app context" re-renders every consumer on any change. Or use a selector store (Zustand, Redux Toolkit with
useSelector). - Keep list
keys stable and unique; never the array index for reorderable lists. - Lift state down, not up; colocate it with the component that needs it.
Virtualise long lists
Rendering 5,000 rows means 5,000 DOM nodes and a huge commit. Render only what is visible with @tanstack/react-virtual or react-window, so the DOM stays a constant size regardless of data length.
Images
- Always set
widthandheight(oraspect-ratio). This alone kills most CLS. loading="lazy"on below-the-fold images; never on the LCP image.fetchpriority="high"on the LCP image; consider<link rel="preload">for it.- Responsive
srcset/sizes; serve AVIF/WebP with fallback.
Fonts
font-display: swap(oroptionalto avoid any shift), self-host, and subset to the characters you use.<link rel="preload" as="font" crossorigin>the one weight needed for the first paint.- Match the fallback font's metrics (
size-adjust,ascent-override) so the swap does not reflow.
Prioritise the network
| Hint | Use for |
|---|---|
| <link rel="preconnect"> | Origins you will definitely hit soon (API, font host, image CDN) |
| <link rel="preload"> | Critical late-discovered resources (LCP image, key font, hero data) |
| <link rel="prefetch"> | Resources for the *next* likely navigation, at low priority |
| fetchpriority="high" / "low" | Nudging the browser’s priority for a specific fetch/img |
A measurement workflow you can keep#
- Field dashboard: p75 LCP/INP/CLS per route from the
web-vitalslibrary, watched over time. - CI budgets: Lighthouse CI assertions and a bundle-size gate (
size-limit/bundlesize) that fail the PR on regression. - Alerts: notify when a route's p75 crosses into "needs improvement".
- Diagnose specifically: when a metric regresses, pull the attribution data, reproduce with throttling, record one trace, fix the one task or resource it points to.
- Re-measure in the field after it ships. Lab confirmation is not the finish line.
web-vitalslibrary; use Lighthouse, WebPageTest and the DevTools Performance panel to diagnose, not to score. In a trace, hunt render-blocking resources, long tasks and layout thrash. Then fix with the boring, effective tools: less JavaScript, split long tasks, debounce settling events and throttle streaming ones, fewer re-renders, virtualised lists, sized images, and preloaded critical fonts.