Home / Articles

Beyond P95: Measuring the Latency Your Users Actually Experience

A healthy P95 can sit next to a slow product. How queue time and fan-out hide from dashboards, and how per-step observability ends latency blame games.

Publisher: REACTAPP.TOP17 min read3974 words

This article is published in English.

Sources: Your P95 Latency Is Lying to You.

A customer reports that the product feels slow. Someone opens the latency dashboard, and everything is green: P95 within target, error rate flat, CPU relaxed, database connections well below the pool limit. The tempting conclusion is that the complaint must be wrong, and that instinct, trusting a chart over a person who just waited, is the real bug.

The sections below explain why a respectable P95 and a sluggish product can both be true at once. You will see where latency hides from service-level metrics (queues, retries, fan-out, blocking decisions), why the tail matters more than its size suggests, and how a simple habit of timing each step of a request by name turns vague complaints into evidence. By the end you should be able to question a "P95 is fine" statement productively instead of either accepting it or dismissing it.

What P95 does and does not tell you

It is worth being precise before criticizing the metric. P95 is the value at or below which 95 percent of measured requests completed. It is a genuinely useful number, and it is a large improvement over average latency. An average is where production pain goes to vanish: if ninety-nine requests finish in 50 ms and one takes five seconds, the mean is roughly 100 ms, a number nobody would page anyone over. A percentile at least pushes your attention toward the edge of the distribution.

So the percentile itself is not the problem. The problem is the role teams give it after reading it. "P95 is fine" often works as a certificate of innocence that ends the discussion. That move quietly forgets something basic: nobody using your product experiences a percentile. People experience waiting. They press a button and wonder whether the press registered. They see a page that looks rendered but will not respond. They watch a spinner while several services behind it each report perfectly reasonable numbers.

An API can have an excellent P95 while the product built on it feels heavy. Holding both facts at the same time is the starting point for everything that follows.

Three clocks run during every request

Most latency conversations assume a single clock. In practice there are at least three, and they measure different things.

  • The server clock. A request arrives at your service, a response leaves, the timer stops. This is what most backend dashboards report.
  • The dependency clock. Inside and around that request, time goes to authentication, waiting for a pooled database connection, internal HTTP calls, cache misses, retries, queues, feature-flag lookups, third-party APIs, and all the components that are "usually fast."
  • The human clock. The user clicked, then waited, then the screen became usable. This is the only interval the user cares about.

Teams tend to optimize the first clock and then describe the product as fast. The diagram below lays the three side by side: the service reports a tidy 180 ms, while the dependency chain includes a 600 ms retry and the person at the keyboard is still asking why the page is loading.

SERVER CLOCK

request in  ------------------------------> response out
              "API P95: 180ms"

DEPENDENCY CLOCK
request in  ---> auth ---> db ---> service A ---> retry ---> service B ---> response out
                 40ms    70ms      120ms        600ms       90ms

HUMAN CLOCK
click  ----------------------------------------------------------------> usable screen
       "Why is this still loading?"

Nothing on the server clock is incorrect. It is simply incomplete. Judging product speed from it is like taking someone's temperature and declaring every organ healthy: occasionally that one reading is exactly what matters, and occasionally it misses the actual illness.

An endpoint is a door, a journey is a building

A useful mental model: a backend endpoint is a single door, while a user journey is a walk through an entire building. Dashboards mostly measure doors. A request enters, a response exits, the graph updates, and everyone relaxes.

Users, however, are not there to admire a door. They want to reach a destination: open the home screen, send a payment, place an order, download a statement, finish one real task without feeling that the product is hesitating over them. That task rarely corresponds to one backend call. Opening a dashboard in a typical single-page app looks more like the fan-out below, where the shell requests profile, permissions, accounts, offers, notifications and limits before anything useful appears.

User Clicks "Open Dashboard"
        |
        v
+----------------------+
| Frontend Shell       |
+----------------------+
        |
        +------> GET /profile
        |
        +------> GET /permissions
        |
        +------> GET /accounts
        |
        +------> GET /offers
        |
        +------> GET /notifications
        |
        +------> GET /limits
        |
        v
Screen Finally Becomes Useful

Each of those six endpoints can look healthy in isolation while the journey as a whole feels broken. A service can meet its SLA and still take part in a slow product. A team can hit its own target and still contribute to a poor experience. When the calls are made sequentially, their latencies add up; when they run in parallel, the slowest one sets the pace, and a browser's per-origin connection limits or a dependency between calls can serialize work you assumed was concurrent.

That is why "our API is fast" is the beginning of an investigation, not an answer. The follow-up questions are uncomfortable but necessary: fast compared to what, fast for whom, and fast measured from where? Inside your service boundary, or from the moment the user clicked?

The remaining five percent is not a rounding error

The name "P95" makes the bad part sound small. Five percent feels like an acceptable cost of operating at scale, the kind of thing a calm green panel can absorb.

Put real volume behind it, though. A system serving one million requests per day with a P95 threshold has fifty thousand requests above that line every day. Each one is a moment where someone might click again, refresh, or abandon the flow and blame the product.

Even that framing is too tidy, because slow requests are not spread evenly across people:

  • One unlucky user can land on the slow path repeatedly.
  • A single enterprise tenant can be pinned to an overloaded shard.
  • One region can degrade while the global aggregate stays acceptable.
  • A high-value workflow can be slow while cheap, frequent calls keep the percentile looking good.

The dashboard sees a distribution. The affected user experiences something personal. From the system's perspective a fraction of requests were slow; from the user's perspective the product wasted their time specifically.

This is why user complaints deserve to be read as data. People do not report in percentiles; they report in stories. "It hangs every time after I hit submit." "The page shows up, but the numbers arrive late." "I have to reload twice." "It works, but it feels sluggish." None of these is vague. Each is a performance report written in plain language, and the common failure is translating it into the wrong metric and concluding that nothing is wrong.

Two services, one P95, very different risk

A single percentile says nothing about the shape of the distribution around it. Consider two services with a similar P95. The first is unremarkable: most requests are moderate, slow ones are only a little slower, and there is no cliff. The second is fast most of the time but occasionally drops into multi-second territory.

SERVICE A

Most Requests        120ms - 260ms
Slow Requests        300ms - 380ms
Disaster Requests    Rare

SERVICE B

Most Requests        40ms - 80ms
Slow Requests        300ms
Disaster Requests    8s - 12s

Both can report roughly the same P95. Only Service B has a trapdoor, and it sits entirely beyond the 95th percentile, where the number cannot see it. Looking at P99, P99.9 and the maximum, or better yet at a latency histogram or heatmap, is what exposes that difference.

A percentile also hides where the pain is concentrated. It will not tell you whether the slow requests belong to one tenant, one endpoint, one database partition, one downstream dependency, one region, one retry path, one saturated thread pool, or one feature that only gets exercised during important demos. Breaking latency down by those dimensions is what turns a number into a diagnosis.

The practical rule: a percentile dashboard should open a conversation, never close it. The question is not only "what is our P95?" but "what kind of pain is sitting behind this number?"

The worst latency lives between teams

Some latency problems are almost pleasant to fix because their cause is obvious. A slow query gets an index. A missing cache gets added, carefully. An oversized payload gets trimmed. An inefficient loop gets rewritten. Everyone understands these.

The painful ones sit in the gaps between teams. Backend reports that the API is healthy. Frontend reports that it is waiting on backend. Platform reports that the gateway is fine. The database team reports that the database is fine. Product reports that users are still unhappy. Every one of them is telling the truth, which is exactly what makes the situation so frustrating.

In these cases the system is not slow in any single obvious place. It is slow in the seams:

  • in the handoff between services;
  • in a retry that doubles a call's duration;
  • in a second request that should never have been made;
  • in a permission check that runs after expensive work has already begun;
  • in a cache miss that goes unnoticed because the endpoint still finishes under the alert threshold;
  • in a third-party service everyone calls an "external dependency," which sounds less awkward than admitting it is part of the user experience;
  • in a queue, before your code path even starts;
  • in serialization, after the business logic is done;
  • on the frontend, because the API returned data shaped for the database rather than for the screen, and the client has to reassemble it.

P95 did not mislead anyone by being false. It misled by being alone. An isolated metric lets every team demonstrate its innocence while the user keeps waiting.

Time spent in a queue is still latency

There is a subtler measurement problem: application-level timers often start too late. By the time your handler or controller begins executing, the request may already have spent time waiting at the load balancer, waiting for a connection, waiting for a worker or thread, or waiting behind other work because the process was technically up but effectively saturated. In Node.js the same effect shows up as event-loop delay: a busy loop means callbacks, including your request handler, start later than the socket received the data.

Then your timer starts, your code runs quickly, and your metric records a fast request. The user does not care where the boundary of your stopwatch was. Waiting before the timer started still counts. So does waiting through retries, waiting for the browser to issue the next required call, and waiting for a disabled button to become clickable.

This is the trap of measurement boundaries. Teams measure what their service can observe, then implicitly treat everything outside that view as nonexistent. Latency you cannot see is not imaginary; it is latency nobody owns, and unowned latency tends to be the most expensive kind because no one is responsible for reducing it. Load-balancer request timings, connection-pool wait metrics, event-loop lag and browser-side timing are the usual ways to bring those intervals back into view.

Naming each step of the request

What typically settles these arguments is not another dashboard, a new vendor, or a bigger observability budget. It is making the slow part impossible to hide by giving each part of the request an honest name.

The snippet below is a Spring Boot handler in Java, but the pattern translates directly to Express, Fastify or NestJS. A LatencyTrace is started for GET /home and tied to the incoming X-Request-Id header. Every meaningful step, loading the user, fetching permissions, calling the accounts and offers services, and mapping the response, is wrapped in trace.time(...) with a descriptive label. In the finally block, trace.logIfTotalCrosses(Duration.ofMillis(700)) emits the breakdown only when the whole request exceeds 700 ms, so the log stays quiet for healthy traffic and detailed for slow traffic. The exact implementation is less important than the habit it encodes.

@GetMapping("/home")
public HomeResponse home(@RequestHeader("X-Request-Id") String requestId) {
    LatencyTrace trace = LatencyTrace.start("GET /home", requestId);
try {
        User user = trace.time("db.user", () ->
            userRepository.findActiveUser()
        );
        Permissions permissions = trace.time("auth.permissions", () ->
            permissionClient.fetch(user.id())
        );
        List<Account> accounts = trace.time("accounts.http", () ->
            accountClient.fetchAccounts(user.id())
        );
        List<Offer> offers = trace.time("offers.http", () ->
            offerClient.fetchEligibleOffers(user.id())
        );
        return trace.time("response.mapping", () ->
            HomeResponse.from(user, permissions, accounts, offers)
        );
    } finally {
        trace.logIfTotalCrosses(Duration.ofMillis(700));
    }
}

Two details are worth noticing. First, finally guarantees the breakdown is logged even when one of the steps throws, which is precisely when you most want it. Second, the steps here run one after another, so the total is roughly their sum; if you parallelize independent calls such as accounts and offers, the trace should still time each one so you can see which of them sets the pace.

Compare what an ordinary latency log gives you with what this produces. The ordinary version is a single line with a total:

GET /home took 1180ms

It is accurate and almost useless, because it says nothing about where the 1,180 ms went. The step-level version answers that immediately:

GET /home took 1180ms

db.user              42ms
auth.permissions     81ms
accounts.http        156ms
offers.http          823ms
response.mapping     28ms

With that output in front of a group, the discussion changes tone. Nobody needs to guess or defend their own service, and "it works on my side" stops being a meaningful contribution. The offers call accounts for roughly 70 percent of the request, so the conversation moves from opinion to evidence. That is the real payoff of good instrumentation: not more graphs, but fewer arguments.

In a Node.js codebase you can get the same effect with performance.now() around each awaited call and a structured logger, or more systematically with OpenTelemetry spans, which carry the same step names across service boundaries so the breakdown survives when the slow step is in someone else's service. For a broader treatment of combining logs, metrics and traces, see building observable Node.js APIs.

Why clean benchmarks can clear a messy system

This is the point where a performance write-up would usually present a benchmark table. There is a good reason not to here. Benchmarks are valuable when they are honest, but an invented one for this problem would just be another misleading number in formal dress.

The issue is not that engineers cannot run controlled tests. It is that controlled tests tend to strip out exactly the mess that hurts users:

  • A benchmark exercises one endpoint; a real user action triggers many.
  • A benchmark runs with warm caches; users show up after deploys, evictions and cold code paths.
  • A benchmark measures successful responses; users sit through partial failures, retries, timeouts and disabled screens.
  • A benchmark assumes stable dependencies; production dependencies are fast right up until the day they are not.
  • A benchmark isolates one service; what users touch is stitched together from many services plus browsers, networks, queues, API contracts, and choices made by teams that may no longer talk to each other.

A benchmark describes how fast a controlled path can be. Production describes how honest the whole system is. Those are different measurements, and one does not substitute for the other.

Fast code does not guarantee a fast product

Many backend engineers find local optimization deeply satisfying. You bring a function from 20 ms down to 8 ms, remove an allocation, restructure a loop, tune a query, and the diff feels clean. Meanwhile the product still waits on a remote service that takes 900 ms on every tenth request. The screen still takes too long to become usable, the button still freezes, and support tickets still say "slow."

That is polishing the brass while the floor gives way. Local work is attractive because it is fully under your control. End-to-end latency is messier because improving it involves other teams, legacy contracts, product choices and architecture nobody is eager to reopen. Users, however, do not reward local elegance; they reward completed actions. A fast function is nice, a fast endpoint is better, and a fast journey is what actually counts. Before optimizing any single component, it helps to confirm it sits on the critical path of a journey someone cares about.

When the slowest dependency is a decision

Not every latency problem is a technical one. Some are decisions that have hardened into architecture:

  • The frontend calls six APIs where one aggregated response would do.
  • Every screen asks the permission service again instead of reusing a result.
  • Retries run synchronously inside the user-facing request path.
  • An offers widget blocks rendering of the entire home screen.
  • Analytics events must fire before the user is allowed to continue.
  • The API returns database-shaped objects and leaves the frontend to assemble meaning.
  • A downstream service is labeled "critical" because nobody was willing to say its feature could load later.

The slowest part of a system is often a product choice that was never measured, an ownership gap, or a meeting from long ago whose outcome became permanent latency. Many of these have well-known remedies, such as a backend-for-frontend that aggregates calls, lazy or deferred loading of non-essential widgets, moving retries and analytics off the critical path, and response shapes designed around the screen. Each remedy, though, requires someone to have the authority to change a decision.

That is why performance work gets political once the easy fixes are done. The remaining latency becomes a question of accountability. Who gets to declare a dependency mandatory? Who owns the full journey? Who is permitted to remove a call? Who is responsible when every service is green and the experience is still bad? P95 cannot answer any of those, and it was never meant to.

Green dashboards can make teams less curious

A less comfortable observation: a green dashboard can make a team stop asking questions. Not always, but often enough to matter. When a graph turns red, everyone investigates. When the graph is green and a user complains, the conversation drifts toward explaining the complaint away. Perhaps their network was poor. Perhaps their browser was slow. Perhaps it was a one-off, or support exaggerated, or the frontend did something odd, or the customer clicked too early, or the complaint is emotional rather than technical.

This is how teams lose trust, not through a lack of data but by using data as a reason not to listen. The user does not have your dashboard; they have your product. If it feels slow to them, that is evidence. It is not complete or perfect evidence, but it is evidence.

A healthy engineering culture treats the graph as one witness and the user as another, with production as the scene to investigate. A culture that treats graphs as truth and users as noise is the one that ships slow products while believing they are fast.

Start from what the user was trying to do

Performance discussions are usually organized around components: which service, which endpoint, which query is slow. Those are good questions, but they are rarely the best first question. The better opening is: what was the user trying to accomplish? Once you answer that, the right measurement tends to follow:

  • For opening a dashboard, measure time until the dashboard is actually ready to use.
  • For submitting a form, measure from submit to visible confirmation.
  • For downloading a report, measure from click to file available.
  • For a payment, measure from start to the final settled state.

This sounds obvious, yet many systems are not instrumented this way. They are measured along team boundaries because team boundaries are easy to instrument and produce tidy dashboards. Journeys are harder to measure and produce less flattering results, and those less flattering results are where product improvements come from. On the client side, browser APIs such as the Performance and User Timing interfaces let you mark "user clicked" and "screen usable" explicitly, and correlating those marks with a request ID ties the human clock back to server traces. If the slow part turns out to be in rendering rather than the network, the article on diagnosing React performance beyond API response time covers that side of the problem.

Treat metrics with respectful suspicion

None of this is an argument to remove P95 from your dashboards. It belongs there, and it matters. It simply should not be trusted alone. A good percentile should make you more curious, pointing you toward the requests on the far side of the threshold:

  • Who exactly lands in the slow five percent, and is it the same people every day?
  • Which user journeys do those requests belong to?
  • Is one dependency responsible for most of the tail?
  • Is a particular region degraded while the global view looks fine?
  • Is a specific tenant consistently getting the worst of it?
  • Are retries quietly converting failures into long waits?
  • How much time do requests spend queued before the application timer even starts?
  • Is some frontend state keeping the screen blocked after the backend has already responded?
  • Is a service healthy on paper but hurting the product in practice?

The right relationship with a metric is neither worship nor rejection. It is respectful suspicion. A metric is a witness, and witnesses can leave important things out.

Key takeaways

The dangerous scenario is not a metric that is plainly wrong; that would be easy to catch. It is a metric that states a small truth so confidently that everyone stops looking for the larger one. The next time someone says P95 is fine, do not argue with the number; ask what it excluded. Did it include queue time, retries, frontend waiting, the third-party call, the user clicking again because nothing seemed to happen, and the moment the screen actually became usable for the task the customer came to do?

  • P95 beats averages but hides distribution shape, tail severity and who is affected; pair it with higher percentiles, histograms and per-tenant or per-region breakdowns.
  • Distinguish the server, dependency and human clocks, and make sure someone owns each one.
  • A user journey usually fans out into many calls; measure the journey, not only the endpoints.
  • Application timers often miss queueing, connection waits and event-loop delay; measure those explicitly.
  • Timing each named step of a request, and logging the breakdown when a threshold is crossed, replaces blame with evidence.
  • Some latency is the result of product and ownership decisions, and fixing it requires someone empowered to change them.
  • Treat user complaints as performance reports and dashboards as one witness among several.