Debugging Distributed Request Latency: Finding Where Time Is Actually Spent
Learn to debug distributed request latency using distributed tracing — critical paths, self-time, tail latency, and sampling, with real production data.

Debugging distributed request latency is hard because a single request no longer runs in one place. It fans out across services, queues, databases, caches, and third-party APIs — some in sequence, some in parallel — and the slowness you see is rarely where you expect it. Guessing, adding logs, and "optimizing the database" usually waste a cycle. This guide lays out a repeatable methodology for attributing wall-clock time to the exact segment responsible, using distributed tracing. Every example here comes from real production traces, including a 5.48-second checkout request and an endpoint whose worst calls were never even recorded.
Quick Answer
To debug distributed request latency, trace one representative slow request end to end, decompose its wall-clock time into per-segment spans, then find the critical path — the longest chain of dependent spans that determines total latency. Rank spans by self-time (time a service spends on its own work) rather than total time, decide whether each dominant span is computing or waiting, and fix only spans on the critical path. Work the tail (p95/p99), not the average, because averages hide the requests that actually hurt users.
What Is Distributed Tracing?
Distributed tracing records the path of a single request as it moves across your system. The model comes from Google's 2010 Dapper paper, which described a trace as "a tree of nested RPCs." A trace is the whole request; a span is one timed unit of work inside it (an HTTP call, a DB query, a block of code). Spans carry a name, start/end timestamps, a status, and key-value attributes like db.statement or http.url. One trace has a single root span (the entry point) and many child spans forming a parent/child tree.
That tree is what lets you stop guessing. Instead of asking "why is checkout slow," you ask "which span, on the path that determines total time, is the largest — and is it burning CPU or waiting on something?"
The Problem: Latency Is an Attribution Problem
The end-to-end latency a user feels is not the sum of all the work the system did. It's the wall-clock time along the longest dependent chain. Consider a real sampled trace of a POST /jobs_from_cart checkout — 5.48 seconds, 153 spans. Ranking its spans by self-time tells the whole story:
off(ms) dur(ms) self(ms) category span
3 5482 5478 generic Middleware/.../jobs_from_cart (root)
1599 3885 2572 generic Callback: collectionOperationCallback
1635 1642 1642 http External/api.braintreegateway.com/.../transactions
23 1493 1490 http External/api.braintreegateway.com/.../transactions
2953 1466 1465 http External/api.braintreegateway.com/.../transactions
2954 1263 1260 http External/api.braintreegateway.com/.../transactions
1635 1241 1238 http External/api.braintreegateway.com/.../transactions
4269 1050 1050 datastore MongoDB/promotions/findOneThose five payment-gateway calls sum to roughly 7.1 seconds of span time inside a 5.48-second request — which is only possible if they overlap. Read the offset column and the structure falls out: one call starts at 23 ms, two start together at 1635 ms, two more start together at 2953 ms. That's a three-wave staircase against a payment gateway. No amount of database tuning touches it, because the database isn't the problem — Braintree round-trips on the critical path are.
This is why summing span durations is a trap, and why you need two distinct measures of time.
Self-Time vs Total-Time
- Total time (inclusive): a span's wall-clock duration, including everything its children did.
- Self-time (exclusive): what's left after subtracting time covered by child spans. This is where a service actually does its own work.

Optimizing total time chases the wrong target: a 300 ms parent whose 275 ms is a child DB call has just 25 ms of self-time — it's a database problem, not a parent-code problem. In the same production system, 81,019 "generic" spans summed to 7,906 seconds over six hours — a meaningless number, because generic spans nest and that sum double-counts massively. Meanwhile 981 external spans (1% of the span count) accounted for 513 seconds of real, non-overlapping wait. Self-time is what keeps you honest.
The Solution: A Tracing-Based Methodology
The fix is a workflow, not a tool. At a high level, a request and its instrumentation look like this:
Client
↓ (traceparent header propagates context)
Edge / API service ── root span
├─→ Auth service ── child span
├─→ Product service ── child span
│ ├─→ MongoDB ── datastore span
│ └─→ Cache ── datastore span
└─→ Payment gateway ── external span ← often the critical pathEach hop injects trace context into the next call so the receiver can attach its spans to the right parent. Get that propagation right and the waterfall reconstructs itself; get it wrong and the trace silently falls apart — which, as we'll see, is exactly what happens in most real systems.
Prerequisites
- A distributed tracing tool (New Relic APM, or OpenTelemetry + a backend). Examples below use New Relic and NRQL.
- Tracing agents installed on every service on the hot path.
- Basic familiarity with percentiles (p50/p95/p99) and your service's SLOs.
- Access to query span data (NRQL, or your backend's query language).
Step-by-Step: Debugging Distributed Request Latency
Step 1: Confirm the Problem in Percentiles, Not Averages
Start from a percentile, never a mean. If only the average moved, suspect a few extreme outliers; if p99 moved, users are hurting. Query the distribution:
SELECT percentile(duration, 50, 95, 99) FROM Transaction
FACET name SINCE 24 hours agoAverages actively mislead. Here's one MongoDB operation on a production backend over six hours:
operation calls avg max
fmapqueues/toArray 395 98ms 10,666ms
users/findOne 535 13.6ms 1,375ms
agents/findOne 1,241 23.4ms 1,236ms
messages/toArray 539 43.1ms 820msfmapqueues/toArray averages 98 ms but its worst call is 10,666 ms — 109× its own average. Any dashboard showing only the mean would mark this query "fine."
Step 2: Find Representative Slow Traces
You want traces in the tail, not average ones:
SELECT * FROM Span WHERE name = 'YOUR_ENDPOINT'
ORDER BY duration DESC LIMIT 20This is where most tracing setups quietly fail — see Step 6.
Step 3: Read the Waterfall for the Critical Path
Open a slow trace's waterfall (a Gantt-style view with spans laid on a time axis, indented by parent/child nesting). The critical path is the chain of spans with zero slack — where making anything faster actually shortens the request. Two patterns to spot immediately:
- Serial calls appear as a descending staircase — each span starts when the previous ends. The Braintree example above is exactly this: three sequential waves. Independent serial calls are an optimization opportunity — parallelize them and the path collapses to the single slowest call.
- Parallel calls stack, starting at roughly the same x-position. Their parent's duration is governed by the slowest child.

Ignore spans with slack. A span running in parallel with something slower can be optimized to zero and total latency won't budge.
Step 4: Rank by Self-Time and Identify the Dominant Span
Sort the trace by self-time (the self-time vs total-time distinction from the Problem section above). New Relic does a version of this for you: in a transaction trace, a slow span is a leaf/exit node whose exclusive duration is ≥25% of total trace duration — it points you straight at self-time on the critical path. See New Relic's transaction trace documentation for how it flags these slow segments.
Step 5: Classify Compute vs Wait
A slow span is fixed differently depending on why it's slow:
- Compute (on-CPU): business logic, serialization, parsing. Fix with a better algorithm, caching, or doing less work.
- Wait (off-CPU): network I/O, a downstream response, acquiring a lock or a connection, sitting in a queue. Fix by parallelizing, tuning pools, setting timeouts, hedging, or applying backpressure.
Check the gap between a CLIENT span and its matching SERVER span — that gap is network plus queue time. Blank space on the critical path (time inside a parent not covered by any child) is uninstrumented time: real self-time, a GC pause, or queue wait you didn't measure. Absence of a span is not absence of work.
Step 6: Check Whether Your Slowest Requests Were Even Sampled
This is the step everyone skips, and it's the most important one. Tracing tools sample. Head-based sampling decides keep/drop at the start of a trace — cheap and predictable, but blind to which traces will turn out slow. Tail-based sampling buffers the whole trace and decides after it completes, so it can preferentially keep slow and errored traces — at the cost of memory and infrastructure.

Why it matters, with data. Here are the five slowest GET /agents/homeScreenDetails/:agentId transactions over 24 hours:
traceId duration
4cd5a3e6... 258.1s
d4e50111... 249.5s
d045d069... 125.2s
67c71b7a... 35.2s
e86b4eaa... 26.3sQuerying the Span table for every one of those trace IDs returned count: 0. None of the five worst requests had any spans recorded. The volume explains it: 117,813 transactions on that app in 24 hours, but only 22,779 distinct trace IDs carried spans. Head-based adaptive sampling kept about 19% — and kept none of the traces you'd actually want to debug. If you rely on head-based sampling, your tail investigation can be starved of the exact evidence it needs. On this endpoint, that happens every single day.
Step 7: Drill In, Form One Hypothesis, Re-Measure
Use span attributes (db.statement, http.url) to reach the specific query or call. Form one hypothesis — "this endpoint is slow because of an N+1 against users" — fix it, redeploy, and re-measure the same percentile on the same endpoint. One variable at a time.
Common Problems / Errors
N+1 Queries
A burst of many near-identical small datastore spans — one list query, then one query per row. In the single jobs_from_cart trace above, the datastore spans included users/findOne ×8, regions/findOne ×4, jobs/findOne ×3, and cities/findOne ×2. New Relic's heuristic is that four or more consecutive identical calls signal an N+1; users/findOne at 8× clears that bar. Fix by batching ($in / findMany) or eager-loading.
Broken Context Propagation
If a proxy, load balancer, CDN, or API gateway strips the W3C traceparent header, the trace breaks at that boundary. A real symptom from production: across ~4,900 spans, only 219 carried a parent.type — 95% were orphan entry points, and the maximum trace width was two services, never three. The 5.48s checkout trace lists a single app; the caller that triggered it is invisible. You're not looking at short traces, you're looking at truncated ones.
Serial Calls That Should Be Parallel
The staircase pattern. Independent downstream calls executed one after another. The three sequential Braintree waves are the textbook case — batch or parallelize them and the critical path shrinks toward the single slowest call.
Connection Pool Exhaustion
Time spent waiting to acquire a connection before a query span even starts. The database is healthy; the pool is the bottleneck. Classic trap: 20 connections per app server × 8 servers = 160 desired, but Postgres max_connections = 100, so requests queue for a slot. It shows up in the tail first.
Retry Storms
Repeated identical downstream spans within one trace, and a self-reinforcing load spike system-wide. Amplification compounds with call depth. Fix with exponential backoff plus jitter, retry budgets, and circuit breakers.
Best Practices
- Rank by self-time, never inclusive time. It's the only way to avoid blaming a parent for its children's work.
- Dashboard the distribution, not the mean. Chart p50/p95/p99 (p99.9 for high-QPS paths) plus a histogram. A widening p99-to-p50 gap is your trigger to open traces.
- Choose sampling deliberately. Start head-based for cost. The moment "we had tracing but the failing request wasn't sampled" happens even once in an incident, move that path to tail-based (New Relic Infinite Tracing, or an OpenTelemetry Collector tail-sampling processor).
- Attack pathologies in order: collapse N+1s → parallelize serial calls → right-size pools against
max_connections→ add backoff-with-jitter and retry budgets → add hedged requests on fan-out reads if the tail still dominates. - Configure `distributed_tracing` explicitly on every service. Don't assume it's on; missing config is why trace width caps at two services.
- Instrument the gaps. Blank time on the critical path means add a manual span there before concluding it's compute.
- Verify measurement honesty. Use open-loop load generators to avoid coordinated omission (a closed-loop tool that waits for responses under-reports the tail), and set timeouts near a realistic tail percentile.
Performance Considerations: Why the Tail Dominates at Scale
Fan-out amplifies rare slowness into common slowness. Dean & Barroso's The Tail at Scale gives the canonical example: if each backend is slow just 1% of the time, a request that must wait on 100 of them is slow about 63% of the time, because 0.99^100 ≈ 0.366. The tail is not a rounding error — at scale it is the user experience.
Little's Law (L = λ × W: concurrency = throughput × latency) ties latency to saturation. If a service takes 50 ms at 2,000 rps it carries ~100 concurrent requests; if latency degrades to 200 ms, that's ~400 in flight — enough to exhaust a pool sized for 100, which adds queue latency, which raises latency further. Size pools and thread counts from L, not throughput alone.
One caution on reading extreme tails: a HEAD handler in this system showed 275 calls averaging 26,823 ms with a p95 of 124,610 ms — and the 258-second homeScreenDetails outliers fit the same shape. Numbers like these usually mean hung connections, health-check probes, or scanners held open against a keep-alive timeout, not users waiting four minutes. Investigate the mechanism before reporting any single tail number as user-facing latency.
When Should You Use Distributed Tracing?
Use it when:
- Requests cross two or more services, queues, or third-party APIs.
- Your p99 is out of SLO and logs can't tell you which hop is responsible.
- You need to distinguish compute from wait, or serial from parallel.
Simpler tools may suffice when:
- The system is a single service with a single database — an APM transaction trace or slow-query log may be enough.
- You only need aggregate throughput, not per-request attribution.
Trade-offs
Distributed tracing is the right tool for latency attribution, but it isn't free. Weigh these before rolling it out everywhere:
- Sampling vs. completeness. Head-based sampling is cheap and predictable but drops the slow traces you most want (as the
homeScreenDetailsdata showed). Tail-based sampling keeps the outliers but needs every span of a trace buffered on one collector, which costs memory and infrastructure. You are trading observability of the tail against operational cost. - Instrumentation effort vs. coverage. Auto-instrumentation captures framework boundaries cheaply but misses your business logic; manual spans cover what matters but are code you write and maintain. Under-instrument and you get orphan gaps on the critical path; over-instrument and you add noise and a little overhead to every request.
- Overhead vs. fidelity. Even at Google, Dapper leaned on sampling precisely because tracing every request at full detail is too expensive at scale. More spans and attributes mean richer traces but more agent CPU, more data egress, and higher backend cost.
- Cardinality vs. queryability. High-cardinality span attributes (user IDs, full URLs) make traces searchable but inflate storage and indexing cost, and can hit backend limits.
- Trace retention vs. budget. Longer retention helps you debug intermittent tail issues that surface days apart, but trace volume is large; most teams keep full traces briefly and aggregates longer.
The practical path is asymmetric: trace the critical paths deeply, sample the rest, and tighten instrumentation only where a real investigation hit a blank wall.
FAQ
How do I find which service is causing latency in a distributed request?
Trace a representative slow request, build its span waterfall, and find the critical path — the longest chain of dependent spans. Rank spans by self-time; the dominant span on the critical path points to the responsible service.
What's the difference between self-time and total time in a trace?
Total (inclusive) time is a span's full duration including its children. Self-time (exclusive) subtracts time covered by child spans, showing the work a service did itself. Always debug with self-time so you don't blame a parent for a slow downstream call.
Why do my average latency numbers look fine when users complain?
Averages hide the tail. A p50 of 40 ms with a p99 of 2 s means 1 in 100 users has a terrible experience the mean will never show. In real data, one query averaged 98 ms but peaked at 10,666 ms. Track p95/p99 and histograms.
Why are my slowest requests missing from tracing?
Head-based sampling decides what to keep at the start of a request, before it knows the request will be slow — so the worst traces are often dropped. In one production app, none of the five slowest daily requests had any spans. Switch critical paths to tail-based sampling.
What is head-of-line blocking in a distributed system?
One long request stuck ahead of short ones in a shared queue, delaying all of them. Mitigate by breaking large requests into smaller units and isolating slow work onto separate queues or pools.
How many services should a single trace span?
As many as the request touches. If your traces never exceed two services, that's a red flag for broken context propagation — usually a proxy or gateway stripping the traceparent header, or missing distributed_tracing config.
Conclusion
Distributed request latency is an attribution problem: the time a user feels lives on the critical path, and your job is to find it, not guess at it. Trace a genuinely slow request, rank its spans by self-time, separate computing from waiting, and fix only what's on the critical path. Along the way, verify the boring things that quietly break everything — that context propagates across every hop, and that your sampling actually keeps the traces you need. Averages will tell you everything is fine while your p99 tells the truth. Read the distribution, read the waterfall, and let the data point to the fix.
Struggling with slow APIs, mysterious p99 spikes, or a tracing setup that never shows the requests that matter? Our engineering team helps companies instrument, diagnose, and fix distributed performance problems in production. Contact us to talk through your latency issues.

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


