Traceswaterfalltimelineshapesqueueingreading traces

Reading the Waterfall

Six shapes cover most of what a waterfall can tell you: the staircase, the comb, the fat leaf, the gap, the overhang and the cliff. Learning to recognize them turns trace reading from scrolling into diagnosis.

Follow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
What is this timeline telling me — and is that long bar work being done, or time being waited?
Symptom
You have the trace open and 40 spans in front of you, and no idea which one is the finding. Everything looks like it takes some time.
Signal
The geometry of the waterfall — offsets, gaps and overlaps — read before any individual duration. Sorting spans by duration alone hides the two most valuable findings, which are gaps (no span at all) and repetition.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

The gap is a finding, not empty space

The single most valuable habit in trace reading is noticing time where *no span exists*. A parent that starts at 0 and whose first child starts at 48 ms spent 48 ms doing something nobody recorded. That is almost never idle CPU: it is a thread-pool wait, a connection-pool acquisition, a GC pause, a cold-start initialization, or a middleware chain nobody instrumented. All of those are real user latency and none of them will ever appear in a "top spans by duration" list.

Gaps between siblings mean the same thing at a smaller scale — the parent did local work, or waited for something untraced, between two calls. Gaps at the *end* of a parent, after its last child returns, are usually response serialization or the framework writing the response, and they get large when payloads get large (JavaScript Costs Four Times, Not Once has the browser-side mirror of this).

The waterfall below has one deliberate 48 ms gap at the start. In a real investigation that gap is the entire finding: the service was not slow, it was queued, and the fix is concurrency limits or pool size, not code (Concurrency Limits: An Unbounded Server Is a Slower Server).

Parallel fan-out done well — with a 48 ms pool wait before any of it starts.
critical pathILLUSTRATIVE
080160240320
SERVER GET /dashboard320 ms
auth-service verify22 ms
inventory bulk lookup120 ms
pricing quote64 ms
shipping estimate40 ms
render + serialize120 ms
SERVER GET /dashboardNothing happens for the first 48 ms. That is the finding.
auth-service verifyRuns first because everything else needs the identity.
inventory bulk lookupLongest of the three concurrent calls, so it sets when rendering can start.
pricing quoteOverlaps inventory — concurrency, correctly rendered.
shipping estimateFinishes first and then waits. Optimizing it buys nothing.
render + serializeStarts once the slowest dependency returns.

Six shapes and what each one means

Shapes generalize across stacks, which is what makes them worth memorizing. The staircase — children stacked end to end with no overlap — is serialized work that may not need to be; it is the most common recoverable latency in a trace. The comb — many narrow identical spans in a row — is an N+1 (The Comb: N+1 as a Visible Shape). The fat leaf — one wide span with no children — is either genuine local compute (profile it) or an uninstrumented call (instrument it), and telling those apart is what When the Trace Runs Out of Answers is for.

The gap is untraced time, discussed above. The overhang — a child extending past its parent's end — is a broken tree and means the durations cannot be trusted (Parents, Children and Links). The cliff — every span normal but the root much longer than their sum — is the same family: time is being spent outside anything you record.

Read shapes before durations. A 400 ms span in a trace whose baseline is 380 ms is noise; a comb of 100 × 2 ms spans in a trace that used to have three spans is a regression that no single duration will flag. The eye is very good at spotting repetition and empty space, and very bad at ranking numbers, so let it do the thing it is good at first.

Waterfall shapes → what to check next
ShapeWhat you seeMost likely causeNext move
StaircaseChildren stacked end to endIndependent calls awaited one at a timeCan they run concurrently? (Sequential or Parallel: Same Work, Different Latency)
CombMany narrow identical spansN+1 query or per-item network callBatch or join (The Comb: N+1 as a Visible Shape)
Fat leafOne wide span, no childrenLocal compute, or an uninstrumented callProfile the process (Self Time, Total Time, and Where the CPU Went)
GapDead time before or between childrenPool/thread wait, GC pause, cold startCheck saturation and pool metrics (Saturation: The Reading Utilization Cannot Give You)
OverhangChild ends after its parentBroken parenting or async nestingFix the tree before trusting any number
CliffRoot ≫ sum of childrenUntraced middleware or framework workInstrument the entry path

A long span is not the same as slow code

The distinction that separates useful trace reading from confident guessing: a span measures elapsed time, which is work plus waiting. A 120 ms inventory bulk lookup span in the caller might be 118 ms of the callee computing, or 3 ms of computing and 115 ms sitting in the callee's request queue, or 40 ms of TLS handshake because the connection pool was empty. All three render identically in the caller's waterfall.

Separating them requires the callee's own SERVER span. The difference between the caller's CLIENT duration and the callee's SERVER duration is network plus queueing — and when that difference is 90 ms you have found a saturated service, not a slow one. This is the single most useful arithmetic in distributed tracing, and it only works when both spans exist and are properly linked (Carrying the Trace Across the Gap).

The same logic applies inside a process. A wide span containing a database call is not evidence the database is slow until you compare it against the database's own reported execution time; the gap between them is client-side queueing, connection acquisition or result deserialization, which is exactly the diagnosis in Connection Pool Saturation: Waiting in Front of an Idle Database.

Reading elapsed time as work
1caller: CLIENT inventory bulk lookup 120 ms
2=> "inventory is slow, ask that team to optimize their query"
3
4# Nothing here supports that conclusion. The 120 ms includes:
5# connection acquisition, network, the callee's own queue wait,
6# the callee's work, serialization, and network back.
Decomposing elapsed time with the callee's span
1caller: CLIENT inventory bulk lookup 120 ms
2 |- (gap 0-> 38 ms) 38 ms connection pool wait
3callee: SERVER GET /inventory/bulk 74 ms
4 |- CLIENT postgres SELECT 12 ms
5 |- (self time) 62 ms <- actual work
6network + queueing = 120 - 38 - 74 = 8 ms
7
8=> the finding is a 38 ms pool wait in the CALLER,
9 plus 62 ms of callee compute worth profiling.
10 The query is not the problem.

The first reading blames another team on the strength of a number that contains at least five different things. The second locates 38 ms in your own connection pool — a fix you own and can ship today.

Key points

  • Read geometry before durations: gaps and repetition are findings the eye catches instantly and a sorted duration list hides completely.
  • A gap before the first child is untraced time — pool wait, GC pause, cold start, uninstrumented middleware — and it is real user latency.
  • Six shapes cover most traces: staircase (serialization), comb (N+1), fat leaf (profile it), gap (saturation), overhang and cliff (broken or missing instrumentation).
  • A span measures elapsed time, which is work plus waiting; only comparing the caller's CLIENT span with the callee's SERVER span separates the two.
  • Overlapping siblings mean concurrency and stacked siblings mean serialization — the fastest way to spot recoverable latency.

Follow the diagnosis

The causal chain, hop by hop — and the readings that invite the wrong conclusion.

  1. 1
    Load → thread pool: request arrives while all pool threads are busy, so it waits 48 ms before any handler code runs.
  2. 2
    Handler → trace: instrumentation starts at the handler, so the 48 ms is outside every span and appears as a gap.
  3. 3
    Waterfall → engineer: the visible spans sum to 272 ms while the root reads 320 ms, and nothing in the "slowest spans" view explains it.
  4. 4
    Pool metrics → engineer: queued-request count and pool utilization confirm saturation, which the trace could only hint at.
What this evidence makes people conclude — wrongly
  • "There is no span there, so nothing was happening." Untraced is not idle. Gaps are the highest-value finding in a waterfall.
  • "The widest bar is the bottleneck." Only if it is on the critical path and only if it changed. A wide parallel span that finishes early costs nothing (The Critical Path Is the Only Path That Pays).
  • "The callee is slow." Not until you have subtracted pool wait and network from the caller's number.
  • "The trace looks the same as last week, so nothing regressed." Compare span *counts* too: same shape with 100 extra narrow spans is a serious regression.

Measure, fix, validate

An optimization is not finished until the metric that motivated it has moved.

How to measure it
  • • For the slow trace, compute parent duration minus the span of its children; anything above ~10% of the request is worth instrumenting.
  • • Diff the span count and shape against a fast trace for the same endpoint before comparing durations.
  • • For each cross-service call, subtract the callee's SERVER duration from the caller's CLIENT duration to isolate network plus queue wait.
  • • Check whether concurrent-looking calls actually overlap on the timeline rather than assuming the code's `Promise.all` did what you think.
What actually fixes it
  • • Instrument the gap first — middleware, pool acquisition, deserialization — because you cannot optimize time you cannot attribute.
  • • Collapse staircases into concurrent calls where the work is genuinely independent.
  • • Raise pool or concurrency limits only after confirming the wait is queueing and the downstream can absorb it ([[concurrency-limits]]).
  • • Profile fat leaves rather than guessing at them; a wide childless span is a question, not an answer.
How you know it worked
  • • The gap should shrink or become an explicit span after instrumentation — either outcome is progress, since the second one localizes it.
  • • After parallelizing, the waterfall should show overlap and the root duration should fall by roughly the sum of the removed serial segments.
  • • Endpoint p99 should move in the same direction and magnitude as the trace suggests; if it does not, the trace was not representative.
  • • Re-check that the critical path moved to a different span rather than the fix simply shaving the same one ([[bottleneck-migration]]).
What it costs
  • • Instrumenting gaps adds spans, which adds cost and can itself add measurable overhead on very hot paths.
  • • Parallelizing raises peak concurrency against downstreams — you can convert your latency problem into their saturation problem.
  • • Larger pools trade queueing for memory and downstream pressure, and past a point make things worse.
  • • Reference waterfalls go stale and need maintaining, or they cause false confidence during the next incident.
Stop it coming back
  • Track spans-per-trace as a metric per endpoint; it catches N+1 regressions that latency alone can miss under low load.
  • Alert on root-span duration minus summed child duration exceeding a threshold — a generic "we lost visibility" detector.
  • Keep a reference waterfall for critical endpoints in the runbook so the next responder has a shape to diff against.
  • Add a load test that asserts pool wait stays near zero at expected concurrency (Load Testing: What Question Is This Test Answering?).

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • ILLUSTRATIVEThe 320 ms dashboard trace is constructed to contain one clean example of each shape worth recognizing.
  • ENVIRONMENT-SPECIFICWhether pool acquisition, middleware and serialization appear as spans depends entirely on your framework and which auto-instrumentation is enabled.

Misconceptions

Claim
“The waterfall shows what the code did.”
Reality
It shows what the *instrumentation* recorded. Everything else is a gap, and gaps are where the interesting bugs hide.
Claim
“Sorting spans by duration finds the problem.”
Reality
It finds the longest recorded operation, which misses gaps entirely and treats a comb of 100 fast queries as 100 unremarkable rows.
Claim
“Concurrency in the code means overlap in the trace.”
Reality
Awaiting inside a loop, an exhausted pool, or a semaphore all serialize work that reads as concurrent in source. The timeline is the ground truth.

Apply it