Portfolio/Writing/Front-end performance: how to measure it, and what to actually fix

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.

MetricMeasuresGoodNeeds workPoor
LCP (Largest Contentful Paint)Time until the biggest above-the-fold element renders≤ 2.5s2.5-4s> 4s
INP (Interaction to Next Paint)Worst-case latency from a click/tap/keypress to the next frame≤ 200ms200-500ms> 500ms
CLS (Cumulative Layout Shift)How much visible content jumps around unexpectedly≤ 0.10.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)
SourceLighthouse, WebPageTest, DevTools; one machine, throttledReal users, real devices/networks
Good forReproducible diagnostics, pre-deploy checks, waterfalls, tracesGround truth, ranking, spotting device/geo-specific issues
Weak atRepresenting your actual user baseTelling you *why*; it is aggregate and delayed
The classic mistake
Optimising the Lighthouse score on your MacBook while p75 INP for real users on mid-range Android quietly regresses. The lab number is a diagnostic tool; the field number is the target. If they disagree, trust the field and use the lab to investigate.

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:

ts
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

ts
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

bash
# Vite
npx vite-bundle-visualizer
# or add rollup-plugin-visualizer to the build and open stats.html

Look 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#

  1. Render-blocking resources in <head>: a synchronous <script>, large non-critical CSS, blocking font requests. These delay the LCP.
  2. 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.
  3. 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.
  4. Excessive re-renders: the same components committing on every keystroke or scroll frame.
  5. Script evaluation cost: long yellow "Evaluate Script" blocks mean too much JS is parsing and executing up front.
  6. Image decode and resize: large images being downscaled by the browser; late-loading images causing shift.
  7. Font swap: text invisible (FOIT) or reflowing (FOUT) partway through load.

The fix toolbox#

Ship less JavaScript

  • Route-based code splitting with React.lazy and Suspense per 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-fns or Temporal over moment, per-method lodash-es imports, 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 async or defer, or on interaction or idle. They are often the biggest INP offender.

Break up long tasks

ts
// 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 through useDeferredValue so typing stays responsive.

Debounce and throttle

Different tools for different jobs:

DebounceThrottle
FiresOnce, after activity has stopped for N msAt most once per N ms during activity
Use forSearch-as-you-type, autosave, recomputing layout on resize, validationScroll handlers, mousemove, drag, firing analytics on progress
If you use the wrong oneThrottled search = a request per keystroke burstDebounced scroll = handler never runs until scrolling stops
ts
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:

tsx
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 requestAnimationFrame instead 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/debounce if you need leading/trailing edges and maxWait.

Cut re-renders

  • Measure first with the Profiler; do not memo blind.
  • React.memo a 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 defeat memo.
  • 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 width and height (or aspect-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 (or optional to 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

HintUse 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#

  1. Field dashboard: p75 LCP/INP/CLS per route from the web-vitals library, watched over time.
  2. CI budgets: Lighthouse CI assertions and a bundle-size gate (size-limit / bundlesize) that fail the PR on regression.
  3. Alerts: notify when a route's p75 crosses into "needs improvement".
  4. 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.
  5. Re-measure in the field after it ships. Lab confirmation is not the finish line.
Summary
Track p75 LCP, INP and CLS from real users with the 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.

Next up
Accessibility law in Europe in 2026: the European Accessibility Act, in force and now enforced

The European Accessibility Act became applicable in June 2025. What is in scope, the WCAG bar it sets through EN 301 549, how member states enforce it, the transition deadlines still ahead, and a practical compliance checklist for front-end teams.

Read next →