The Half of the Budget You Cannot See From the Server
Your p99 is 80ms and users still call the app slow. Server time is one line item in a budget that also contains DNS, TLS, render-blocking CSS, JavaScript parse and execute, and an image decode on a phone three years older than your laptop.
Frame the diagnosis
Performance work starts from a symptom and a signal — never from a resource dashboard.
One request, two budgets
Backend observability stops at the response byte. The user's clock starts before DNS resolves and stops when the page is *usable* — which can be seconds after your handler returned. Between those two points sit phases your service has no visibility into: connection setup, render-blocking stylesheets, script download, script parse and compile, script execution, layout, and the decode of whatever image the user actually came to see.
The budget below is an accounting device, not a timeline: in a real load several of these overlap, and the browser starts speculative fetches before the parser reaches them. Treat it the way you would treat a Latency Budgets: Spending 200 Milliseconds on Purpose on the server — a way to see which line item you can plausibly move, and which one is already small.
The important structural fact: TTFB is the only line your backend dashboards can see, and in this budget it is 16% of the total. Halving it — a heroic backend project — buys 200ms of a 2500ms experience. Removing 300KB of JavaScript that nothing on the first screen needs buys more, faster, and shows up in the field data within a day.
Lab data and field data answer different questions
A synthetic run in CI on a fixed machine and a fixed network is a *regression detector*: it is repeatable, so a change in the number means a change in your code. It is not a description of your users, because it has one device, one connection and no cache states.
Field data is the opposite. It describes your users exactly and is useless for pinpointing a cause, because it mixes a flagship phone on 5G with a four-year-old Android on a congested cell in the same percentile. It also arrives late — a meaningful field percentile needs days of traffic, not the twelve minutes your deploy pipeline takes.
Use both, for the jobs they are each good at. The failure mode is running only one: teams with only lab data ship regressions their users feel and their CI never sees; teams with only field data know they got slower last Tuesday and cannot say which of the eleven merges did it. This is the same "no single signal explains everything" argument as Metrics, Logs, Traces, Profiles, applied at the browser.
| Synthetic / lab | Field / RUM | |
|---|---|---|
| Answers | "Did this commit make it slower?" | "How slow is it for real people?" |
| Device and network | One you chose — repeatable, unrepresentative | The real mix, uncontrollable |
| Cache state | Usually cold, by construction | Mostly warm; cold visits are a minority you must segment out |
| Feedback speed | Minutes — fits in CI | Days — a percentile needs volume |
| Attributes a cause | Yes: one variable changed | Rarely: everything changed at once |
| Blind to | Your actual users | Which change caused the shift |
Segment before you conclude
An aggregate "LCP p75 = 3.1s" is a number you cannot act on. The same aggregate can mean *everyone is mediocre* or *desktop is excellent and a third of mobile is terrible* — and those have different fixes. Before forming any hypothesis, split by device class, connection type, route, and first-visit versus repeat-visit.
The segmentation almost always finds the shape: one route with an unoptimised hero image, one device class hitting a JavaScript execution wall, or cold visits paying a connection cost that warm visits never see. That is the point at which a hypothesis becomes worth writing down and testing, in the sense From Symptom to Root Cause means.
Beware the survivorship bias built into field data: users whose experience is bad enough to abandon the page may never report a metric at all. A route that "improved" after a change sometimes only lost its slowest users. Cross-check with a business metric (completion rate, bounce) before declaring victory.
| Signal | Value | What it tells you | Verdict |
|---|---|---|---|
| LCP p75, all traffic | 3.1s | Above target, cause unknown — not actionable on its own | suspect |
| LCP p75, desktop | 1.4s | Comfortably fine; not where the problem lives | normal |
| LCP p75, mobile | 4.6s | The aggregate was an average of two different products | smoking gun |
| LCP p75, mobile, repeat visit | 2.2s | Warm cache hides it; the pain is concentrated in first visits | suspect |
| INP p75, mobile | 420ms | Interaction is also bad — points at main-thread work, not just bytes | smoking gun |
| Server p99, same routes | 78ms | Backend is not the constraint; stop looking there | normal |
Key points
- Backend dashboards see one line of the user's budget; TTFB is frequently well under a quarter of perceived load time.
- Lab data detects regressions and attributes causes; field data describes reality. Running only one leaves a specific, predictable blind spot.
- An unsegmented percentile is not actionable — split by device, connection, route and cache state before forming a hypothesis.
- JavaScript execution cost scales with the device; network cost scales with the connection. They need different fixes.
- Verify improvements against a business metric too: a percentile can improve because the slowest users left.
Progressive depth
Overview
The user's clock starts before your server hears about the request and stops long after it responds. Frontend performance is the accounting for everything outside that window.
Practical
Measure field LCP/INP/CLS per route segmented by device and connection; measure lab metrics per commit for regressions. Find the biggest actionable line item and attack that one, not the one you have the most tooling for.
Advanced
Separate network-bound cost (bytes, round trips, priority) from CPU-bound cost (parse, compile, execute, layout). They scale with different things — connection versus device — so the same fix does not help both, and a change can trade one for the other.
Internals
The browser is a pipeline: the HTML parser discovers resources, the preload scanner speculatively fetches ahead, render-blocking stylesheets gate the first paint, and script execution occupies the same main thread as layout and input handling. Performance work is scheduling work on that shared thread and controlling what the parser discovers, and when.
Follow the diagnosis
The causal chain, hop by hop — and the readings that invite the wrong conclusion.
- 1User → page: taps a link on a mid-range phone over a congested mobile connection; the clock the user cares about starts now.
- 2Browser → network: DNS, TCP and TLS cost ~300ms before a single application byte moves; on a repeat visit this is near zero.
- 3Server → browser: handler returns in 78ms, TTFB lands at 400ms — the backend has now spent its entire share of the budget and is finished.
- 4Browser → main thread: render-blocking CSS and a 400KB script must download, parse, compile and execute before the page is interactive.
- 5Main thread → LCP: the hero image is discovered late, fetched, decoded, and painted at 2.9s; the user has been looking at blank space for the whole interval.
- • "Server p99 is 78ms, so the app is fast." The server is one line item; the user's clock ran for three seconds.
- • "It is fast on my machine." A developer laptop on office fibre with a warm cache is not a device class any of your users have.
- • "The aggregate p75 improved, so the change worked." Check the segments — desktop improving can hide mobile getting worse.
- • "It is a network problem, we cannot fix it." Parse and execute cost is CPU on the device and has nothing to do with the connection.
- • "Lighthouse says 95, we are done." A lab score on a synthetic profile is a regression signal, not a description of your users.
Measure, fix, validate
An optimization is not finished until the metric that motivated it has moved.
- • Field: LCP, INP and CLS as p75 distributions per route, segmented by device class, effective connection type and first-vs-repeat visit.
- • Lab: a synthetic run per commit on a pinned device and throttling profile, tracking the same metrics plus total transferred bytes and main-thread time.
- • Navigation Timing phases (`responseStart`, `domContentLoadedEventEnd`, `loadEventEnd`) to split network from processing.
- • Long-task count and total blocking time as the main-thread proxy for interaction readiness.
- • Server-side p99 for the same routes, purely to rule the backend in or out before spending time on it.
- • Find the largest actionable line item in the field-segmented budget first — usually script execution on mobile or a late-discovered LCP image, not TTFB.
- • Remove or defer JavaScript the first screen does not need; this attacks download, parse, compile and execute simultaneously (see [[javascript-bundle-cost]]).
- • Make the LCP resource discoverable in the initial HTML and prioritised, so its fetch does not queue behind script (see [[image-performance]]).
- • Eliminate render-blocking work that is not needed for the first paint; inline what is critical, defer the rest (see [[browser-waterfall]]).
- • Only then look at TTFB — and when you do, treat it as a normal backend latency investigation with the rest of this domain.
- • Compare field p75 for the affected segment (device class × route) across a window long enough to be stable — typically 7 days before against 7 days after, not the afternoon of the deploy.
- • Confirm the lab run moved in the same direction on the same metric; if lab moved and field did not, you optimised something your users were not paying for.
- • Check that no other metric regressed: shrinking a bundle by deferring work can push cost into INP.
- • Cross-check a business metric over the same window so you know the percentile improved for the right reason.
- • Real-user monitoring is per-visit telemetry: it costs bytes on the client, has privacy implications, and adds a data pipeline to run.
- • Aggressive deferral improves the first paint and can make interaction worse — work moved is not work removed.
- • Per-device-class segmentation multiplies dashboards and alert rules; the cardinality has to be bounded deliberately (see [[cardinality]]).
- • A synthetic CI run adds minutes to every build and needs a pinned environment that someone has to maintain.
- • A CI budget on transferred bytes and main-thread time per route, failing the build when a merge exceeds it.
- • A synthetic run per commit on a pinned mid-range device profile, tracked as a trend rather than a pass/fail score.
- • A field-data alert on LCP/INP p75 per device class, with enough of a window that it does not fire on daily traffic-mix noise.
- • A review checklist item for any new dependency added to the first-screen bundle: what does it cost on the slowest supported device?
Accuracy
Performance numbers are conditional. These are the conditions.
- WEB-SPECIFICPhase names and timing APIs are browser-platform concepts; native mobile and desktop apps have an analogous budget with different instrumentation.
- ILLUSTRATIVEThe 2.5s budget split is invented to show the shape of the accounting. Real splits vary enormously by site, device and connection — measure your own.
- ENVIRONMENT-SPECIFICConnection setup cost depends on protocol version, connection reuse and whether the origin is behind a CDN.
Misconceptions
Apply it
Where the depth lives
A percentile improving because impatient users left is indistinguishable from a real improvement unless a business metric is measured alongside it.