Skip to contentNew book: Designing Human-Centered AI InterfacesGet notified
Abhishek Anand

Performance Engineering//7 min read

Web Performance: Find the Wait, Then Fix It

A practical method for finding slow user journeys, tracing the cause, and checking that a performance change actually helps.

Abhishek Anand

Abhishek Anand

UX Engineer at Google

Performance · Web Vitals · Frontend · Architecture

A page can score well in a loading test and still feel slow when someone changes a filter. An API can respond quickly while the browser spends another second turning its response into a table. Performance work starts by naming the delay that matters to the person using the product.

I find it more useful to follow one slow journey through the system than to begin with a list of optimizations. That gives each change a reason: the trace shows a wait, the change addresses its cause, and a second measurement shows whether it helped.

Start with the delay a user notices

Consider a reporting page. Opening it, changing a date range, and exporting a file are three different journeys. The first might be limited by downloading JavaScript, the second by a database query, and the third by generating a large file. A single page-load average cannot tell you which one needs work.

Define the start and end of the journey before measuring it. For a filter change, that might be the user's input and the point at which the updated rows are visible. Ending the timer when the response arrives would exclude parsing, rendering, and layout.

Record the conditions too: route, release, device class, connection, and whether the cache was warm. A fast desktop on office Wi-Fi is a useful development environment, but it does not represent every user.

Use field data and a trace together

Field data tells you which users and journeys are slow. A controlled browser trace lets you inspect what happened during one reproduction. Use the first to choose the problem and the second to investigate it.

The Core Web Vitals are Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS). The published good thresholds are LCP at most 2.5 seconds, INP at most 200 milliseconds, and CLS at most 0.1, assessed at the 75th percentile. INP replaced FID in March 2024. See the Web Vitals documentation (opens in a new tab) for definitions and assessment guidance.

Those metrics describe loading, responsiveness, and visual stability. A long export or background calculation also needs a product-specific measurement. INP does not measure the full time until an asynchronous workflow finishes.

Register Web Vitals callbacks in client code
import { onCLS, onINP, onLCP } from 'web-vitals';

function reportMetric({ name, value, id }) {
  // Development example: replace with your telemetry transport.
  console.log({ name, value, id });
}

onCLS(reportMetric);
onINP(reportMetric);
onLCP(reportMetric);

The web-vitals library (opens in a new tab) handles the browser measurement details. Register it once per page load. Production collection also needs release context, sampling where appropriate, and a transport that works when the page is being hidden. A console log is only enough to check the integration locally.

Compare distributions within relevant groups. An overall average can hide a regression affecting one device class or a less common route. Avoid comparing a warm-cache desktop run with a cold-cache mobile run and calling the difference an improvement.

Follow the loading dependencies

Suppose a large image appears late. Compressing it may help, but the browser might not discover its URL until a script has downloaded, executed, and fetched page data. In that case, the late start is part of the problem.

An illustrative loading dependency chain
An illustrative loading dependency chain The browser cannot discover the image URL until page data has loaded, which itself waits on a script that only runs after the HTML arrives, so the image download starts later than it needs to. 01 HTML arrives 02 JavaScript loads 03 Page data loads 04 Image URL becomes known 05 Image downloads and renders

Inspect the waterfall before changing asset sizes. Put critical content in the initial HTML when the application can do so. Avoid lazy-loading the likely LCP image, and reserve image dimensions so content does not move when it arrives. The LCP optimization guide (opens in a new tab) separates discovery, download, and rendering delays.

Split code around things the user does not need immediately, such as an export dialog or an optional chart. Then check the first use of that feature. A smaller initial bundle can simply move a noticeable wait to the next click.

Preload resources only when you know they are needed early. Every high-priority download competes with another request. A page with many preloads may be telling the browser that everything is urgent.

Keep interactions responsive

When a click feels stuck, inspect the main thread around that event. The delay can occur before the handler starts, inside the handler, or while rendering the next frame. The INP optimization guide (opens in a new tab) describes these phases.

Remove work before trying to schedule it more cleverly. A table showing twenty rows should not rebuild thousands of hidden row components on every selection. Stable row identity, pagination, and virtual scrolling address different parts of that cost.

Move substantial computation to a worker when it does not need the DOM, and account for the cost of transferring data. Break remaining work into smaller tasks where useful. Merely marking a function async does not move synchronous work off the main thread.

For search, debouncing can reduce requests while someone types. It also adds a deliberate wait. Choose that delay from the interaction you want, and make sure an older response cannot overwrite a newer result.

Trace the request past the browser

Once the browser is waiting on a response, follow the request into the service. Separate time spent waiting for a connection, querying storage, calling another service, and serializing the result. Adding replicas will not fix every slow query, and reducing JSON size will not fix a connection pool at capacity.

Look for work repeated per row. A list request followed by one lookup for each record can make latency grow with the result count. Batch related reads where the data model allows it, and inspect query plans before adding indexes. An index helps particular access patterns and also costs storage and write work.

Request only the data needed for the current view. Paginate large results and put expensive historical detail behind a separate action if the main task does not need it. This can reduce database work, transfer size, parsing, and rendering at the same time.

Decide how stale the data can be

Start a cache design with the data's meaning. A versioned script and an account balance have different freshness requirements. The fact that a response is expensive to generate does not make an older copy acceptable.

For HTTP caching, no-cache allows storage but requires validation before reuse. no-store tells a cache not to store the response. These are different controls, as defined in RFC 9111 (opens in a new tab).

An application cache also needs a key that captures the relevant tenant, permissions, filters, and data version. Check what happens after a write or a permission change. A high hit rate is not useful if the cached answer is wrong or visible to the wrong person.

Test a cache miss as well as a hit. If many requests expire together, they can all reach the origin at once. Request coalescing or staggered expiry can help when that pattern appears, but measure the workload before adding more cache layers.

Show progress without hiding failure

A fast acknowledgement tells someone that their action registered. Preserve the page structure while data loads, keep existing results visible when appropriate, and make it clear when those results are being refreshed. A loading indicator should explain a wait, not disguise a frozen interface.

Optimistic updates suit actions where failure can be recovered from clearly. If a save fails, preserve the edit and offer a retry. Avoid showing success for a consequential action before the server has accepted it.

Progressive loading also needs stable focus and keyboard behavior. If a table inserts new rows while someone is navigating it, the interface can feel faster while becoming harder to use. Include that interaction in the review.

Prove the change and keep the result

Re-run the same journey under comparable conditions after making a change. Check the trace to confirm the intended work disappeared or became shorter. Then check field data after release to see whether the affected users benefited.

Set budgets around those measurements. A bundle-size check can catch a dependency regression; a repeatable browser test can catch a new request waterfall. Keep lab thresholds tolerant of normal variance, and use them alongside production monitoring.

Write down the cause, the change, and the evidence. That record is more useful to the next engineer than a claim that the page is now optimized. It explains which constraint mattered and what to check when the application becomes slow again.

Continue reading

More from the journal.

All writing