Performance & Optimization

Tech Leads, Retire Your Pre-2025 Performance Playbook

A web app can clear its load-time budget and still feel slow every time someone filters a table or opens a menu. Over the last two years, browser metrics and framework defaults have made that mismatch harder to ignore. For a 15–30 person engineering organisation, I would stop treating the Lighthouse score as the performance release gate. It catches regressions; it does not tell you where users are waiting.

INP made load-only performance dashboards inadequate

Interaction to Next Paint (INP) replaced First Input Delay (FID) as a Core Web Vital in March 2024. That change matters because FID measured the delay before the browser began handling a first interaction, while INP reflects the time through the next visual update and can surface a slow interaction later in the visit. A page that loads quickly but stalls when a user edits a row is no longer well represented by a green loading dashboard. Google’s published “good” INP threshold is 200 ms at the 75th percentile of visits; those are vendor-published criteria, not a sensible promise that every interaction will finish in exactly that time.

I would treat High Impact Performance Tuning for Modern Web Apps as a prompt to profile the whole user journey rather than to chase a single page-load score, because INP can expose work that starts long after the initial render. Keep Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) on the dashboard, but add INP by route and device class. Otherwise, an improvement to first paint can conceal a regression in the action people came to perform.

The newer diagnostic opportunity is to connect field interactions to main-thread work. The web-vitals attribution build, Chrome DevTools’ Performance panel, the Long Animation Frames API, and PerformanceObserver can help distinguish input delay from slow event handling and delayed presentation. Use browser support checks before relying on Long Animation Frames in production telemetry, because an absent entry must not be interpreted as proof that an interaction was cheap. Record an interaction’s route, component or action name, and duration; avoid sending typed values or user-generated text, because neither is needed to locate the stall.

For a measured baseline, take an example from one of your own traces rather than an average across the product: an interaction that spends 70 ms waiting, 110 ms in handlers, and 65 ms before presentation calls for different fixes at each stage. Those figures are illustrative trace readings, not benchmark results for another team. Splitting the work this way prevents a common misdiagnosis: shortening an API response will not remove a synchronous sort or a large React render on the browser’s main thread.

Framework cache defaults now deserve the same scrutiny as application code

Advice to “upgrade the framework, then tune the bundle” is increasingly incomplete because a version change can alter which requests reach the origin. In Next.js 15, fetch requests and GET Route Handlers became uncached by default. If a team upgrades without checking its route-level caching decisions, a page can trade stale data for more server work and worse response times under load. The right response is not to cache everything: mark data that can tolerate reuse explicitly, and leave user-specific or rapidly changing responses uncached for a reason you can state.

Write down the cache contract for the handful of routes that carry most traffic. For each, identify the owner of freshness, whether Cache-Control permits a shared cache, and what should invalidate a response. Then verify the contract at the browser, CDN, and origin rather than inferring it from a framework setting. An origin trace that improves while the browser still waits on a blocking request is evidence that the bottleneck moved, not that the journey improved.

React 19’s scheduling tools do not make expensive computation free: a transition can change when work is prioritised, but the work still needs CPU time. I would not wrap every slow update in a transition, because that can make input feel more responsive while preserving the long task that delays the eventual result. First check whether the handler repeats parsing, sorting, or rendering that can be reduced; then use scheduling where immediate feedback genuinely needs priority. An initial scripting budget of 40 ms per frequent interaction is a value to tune against your devices and users, not a browser standard.

On the server, use OpenTelemetry JavaScript 2.0 spans to follow the same action through the API and dependencies, and use Prometheus histograms for a small set of stable route groups. Keep raw IDs out of metric labels, because a label per account or document can create an unmanageable series count. This is a manageable division of labour for a small organisation: one team owns the user-visible journey, while service owners contribute traces that explain where its time went.

Lab tests should catch change, not decide whether users are satisfied

Lighthouse remains useful because a repeatable run can flag a heavier script, a delayed LCP element, or excessive Total Blocking Time before deployment. It is a poor standalone release verdict because its simulated device and network do not reproduce a logged-in user’s data, browser extensions, cache state, or tenth interaction. Total Blocking Time is particularly easy to misuse as a substitute for INP: it measures lab main-thread blocking during loading, whereas INP is gathered from interactions across real visits.

Run a lab check against a representative local page with Chrome installed and the app listening on port 3000:

#!/usr/bin/env bash
set -euo pipefail
URL="${1:-http://localhost:3000}"
npx --yes lighthouse@12 "$URL" \
  --only-categories=performance \
  --form-factor=mobile \
  --throttling-method=simulate \
  --output=json \
  --output-path=/tmp/perf-baseline.json \
  --chrome-flags="--headless"
node -e 'const r=require("/tmp/perf-baseline.json"); for (const id of ["largest-contentful-paint","total-blocking-time","speed-index"]) console.log(id,r.audits[id].displayValue)'

That command produces a reproducible lab record; it does not measure INP in the field. Pair it with a Playwright journey that logs in, reaches the busy screen, and performs the action your team is considering optimising. Use the same seeded data and browser version when comparing changes, because a different account or result set can overwhelm the effect of a code change.

For release decisions, compare field data for the affected route and device class with a pre-change baseline. Start a rollout at 5% of traffic if your deployment system supports it; that is an operational setting to adjust for traffic volume, not a universal safe percentage. Look for a change in interaction duration and error rate together, because a faster path that fails or silently skips work is not a performance win. If traffic is too low for a credible route-level comparison, use traces and the scripted journey to decide what to investigate, and say explicitly that the field result remains uncertain.

I would read Optimize Software Performance for Faster Apps beside one slow production trace, because a useful optimisation idea still needs evidence that it addresses this app’s current bottleneck. That discipline also limits dashboard sprawl: the tech lead needs a small number of decision-making views, not a new chart for every metric the tooling can emit.

Demand-driven loading usually beats speculative loading for a small team

Speculation Rules can ask a supporting browser to prefetch or prerender likely next pages, and HTTP/3 can improve some connections. Neither should be the default answer to a slow navigation, because both act around the journey’s underlying work rather than necessarily reducing it. First find out whether the delay comes from an oversized response, serial data fetching, a cold origin, or browser rendering; the intervention should match the wait.

The explicit choice is Speculation Rules prefetch versus demand-driven loading. Prefetch wins when the next navigation is highly predictable and the response is safe to fetch ahead of time; it costs bandwidth, cache space, and extra requests that may never be used. Demand-driven loading wins when users branch among many destinations or responses are personalised; it costs a visible wait on the navigation you did not anticipate. Prerender has a higher potential payoff than prefetch because it prepares more of the destination, but it also needs stricter care around side effects, authentication, and browser support.

For a 15–30 person organisation, I would test speculation on one high-confidence transition rather than add rules across the site, because a narrow experiment makes wasted requests and user benefit observable. Measure the transition with and without the rule, and watch origin request volume as well as navigation timing. Do not count a faster navigation as a net win if prefetches materially increase load on an API already near its limit. Likewise, test HTTP/3 from the networks your users actually use; protocol support alone does not show that it removes their dominant delay.

The outdated advice here is to apply every available accelerator after a good Lighthouse run. Loading less code and requesting less data are less glamorous, but they reduce work on both cache hits and misses. When a screen needs only one slice of a dataset, changing the request and render path is often easier to operate than predicting which full screen a user will open next. Make that choice from a trace, not from the availability of a browser feature.

The first decision should be about one slow interaction

At the next planning meeting, pick one frequent, slow interaction and assign an owner to capture a field INP attribution sample, a browser trace, and its corresponding server trace. Ask that owner to name the largest avoidable wait before proposing a fix. That small evidence packet gives the team a decision it can test in one rollout—and a reason to leave unrelated performance work alone.