DEV Community

Cover image for Most of Your Latency Is Not Your Code
Anton Brilliantov
Anton Brilliantov

Posted on

Most of Your Latency Is Not Your Code

Cover reading Most of Your Latency Is Not Your Code, with the word Not on a vermilion slab, the figure 7 % was the blamed service, and a ring with a 7 % arc for the blamed service's share of a 1 256.9 ms call

A service is judged by its own span, not by the client's stopwatch.


๐Ÿ‘‹ Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about one write path that left the monolith and what it took to accept it as working. This part is about latency: where it actually sits, and how long we argued about it before anyone measured. Running notes live on my GitHub: github.com/brilliant-almazov.

This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.


The path in one paragraph

One feature: a user saves a set of matching rules, and every domain under that account gets marked according to them. Four participants. The Go API gateway is the only door from outside. The rule-set-markup service takes the write, folds the cascade and persists it. The domain-rule-map service owns rule-set identity and does the recalculation, with a worker process behind a queue. Storage is PostgreSQL - the monolith's database for the rule data, the domain-rule-map service's own for identity.

One user action, four processes, two databases. When it is slow, everyone in the chain has an opinion about whose fault it is, and none of the opinions cost anything to hold.

The series system map with the UI, the Go API gateway, the rule-set-markup service and the domain-rule-map service lit, the PHP monolith and the acceptance repository dimmed, and a note on the domain-rule-map service: 775.6 ms recalculation

The call everyone argued about

A single write, traced end to end: 121 spans, four services, 1 256.9 ms as seen by the client.

Animated trace waterfall of one write: the client bar 1 256.9 ms, the gateway 1 012.0 ms, the rule-set-markup service 943.0 ms, the domain-rule-map recalculation 775.6 ms and its worker 350.9 ms unroll one after another, then a note marks the blamed service's own zone, 87.4 ms, 7.0 %

The blame went to the write service, because that is the one the request is addressed to. Here is the same call as nesting, which is the only shape that answers the question:

client wait                                          1 256.9 ms   100 %
โ”œโ”€โ”€ transport, client โ†” environment                     244.9      19.5 %   not ours
โ””โ”€โ”€ gateway root                                      1 012.0      80.5 %
    โ”œโ”€โ”€ auth, rate limit, identity, cache                 3.7
    โ”œโ”€โ”€ own work + 4 SQL + one catalog call              47.4
    โ””โ”€โ”€ batch write call                                950.9
        โ”œโ”€โ”€ hop, gateway โ†’ write service                  7.9              not ours
        โ””โ”€โ”€ rule-set-markup                              943.0
            โ”œโ”€โ”€ own work                                   1.4
            โ”œโ”€โ”€ validate + gather, 5 SQL                  32.3
            โ”œโ”€โ”€ identity calls โ†’ domain-rule-map          76.7
            โ”œโ”€โ”€ its own transaction, 10 SQL               54.4   (SQL 53.7 ยท work 0.7)
            โ””โ”€โ”€ recalculation โ†’ domain-rule-map          775.6
                โ””โ”€โ”€ worker: canonicalise                  350.9
                    โ”œโ”€โ”€ SELECT dictionary rows            129.6
                    โ””โ”€โ”€ INSERT dictionary rows            112.5
Enter fullscreen mode Exit fullscreen mode

Three nested frames: client wait 1 256.9 ms holds four services at work 1 012.0 ms, which holds the blamed service's own zone 87.4 ms, split into own work 1.4 ms and PostgreSQL 86.0 ms, with a stamp reading 7.0 %

The service under suspicion owns three rows of that tree: its own work, its validate/gather step and its transaction. 87.4 ms out of 1 256.9 - 7.0 % of what the client waited. And inside those 87.4 ms, 98.4 % is waiting on PostgreSQL; the code itself accounts for 1.4 ms.

Two things worth saying out loud about that tree:

  • Never sum nested spans. 943.0 already contains 775.6, which already contains 350.9. Adding them up is how a 1.2-second call becomes "three seconds of work".
  • The whole call cost 53 database round trips, 11 of them repeats of a query already made in the same call. That count, not the milliseconds, is the actionable number - and I'll come back to it.

A dark terminal card with the call as a tree - client wait 1 256.9 ms, transport 244.9, gateway root 1 012.0, batch write call 950.9, rule-set-markup 943.0, recalculation 775.6, worker 350.9 - with the rule-set-markup line highlighted and a note: 943.0 holds 775.6

The rejected request that settled the zone question

Arguments about "the network is fine, it's your service" ended with the cheapest measurement in the whole set: a request that was rejected as invalid.

POST โ€ฆ โ†’ 422 Unprocessable Entity
  server work    6.6 ms
  database       0.0 ms
  client wall  231.8 ms
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  225.2 ms, or 97.2 % of the call, is not the service
Enter fullscreen mode Exit fullscreen mode

The server parsed a body, failed validation and answered in 6.6 ms. The client saw 231.8 ms. Nothing was computed, nothing was stored, and 97 % of the call still happened outside our code.

A dark log card of the rejected request: POST to 422 Unprocessable Entity, server work 6.6 ms, database 0.0 ms, client wall 231.8 ms, and the highlighted line 225.2 ms, 97.2 % of the call, is not the service, with a note: not ours

One dark bar for the rejected call with a narrow green sliver at its left end, labelled 225.2 ms outside our code, a note reading server work 6.6 ms, database 0.0 ms, and a stamp reading client wall 231.8 ms

That is not a defect and it is not fixable by us - the environment sits at a steady 220-260 ms regardless of endpoint or payload size, confirmed on a rejection and on a full run. It only becomes a lie when someone quotes the client's stopwatch as a service metric.

So the reading rule for the whole series, written down after this:

A service is judged by its root and server spans. wall describes the trip, not the work.

Three builds, one input

The same read request - two domains, one account - run three times on three consecutive builds of the matching service, same day:

  • client wall: 715.9 โ†’ 324.0 โ†’ 433.1 ms
  • server root: 474.7 โ†’ 76.2 โ†’ 59.1 ms
  • database total: 440.0 โ†’ 37.2 โ†’ 31.8 ms
  • database queries: 92 โ†’ 7 โ†’ 7
  • network: 242.1 โ†’ 250.1 โ†’ 381.3 ms, i.e. a share of 33.8 % โ†’ 77.2 % โ†’ 88.0 %

Read the network share alone and the story is "it got dramatically worse: the network went from a third of the call to almost all of it". Read the row above it and the story is the opposite: the part we own fell by a factor of eight, and the part we don't own never moved.

  wall         715.9 โ•ฒ___324.0 โ•ฑโ€พ433.1     the trip bounced around
  server root  474.7 โ•ฒ______76.2 โ•ฒ_59.1    โ—„ what we own โ€” fell monotonically
  database     440.0 โ•ฒ______37.2 โ•ฒ_31.8    โ—„ 14ร— drop
  queries         92 โ•ฒ_________7 โ”€โ”€โ”€ 7     โ—„ the shape of the query is what got fixed
  network      242.1 โ”€โ”€โ”€โ”€โ”€250.1 โ•ฑโ€พ381.3    โ–‘ not ours โ€” our fix did not move it
Enter fullscreen mode Exit fullscreen mode

Animated columns for builds A, B and C in four panels: server root 474.7, 76.2, 59.1 ms; database 440.0, 37.2, 31.8 ms; queries 92, 7, 7; network 242.1, 250.1, 381.3 ms - the three we own fall while the network does not

Hence the second rule, which I now apply to every chart in this material: a share is never published without its absolute value. 440.0 ms ยท 61 %, never 61 %. A percentage of a moving denominator is the easiest honest-looking way to say the wrong thing.

What build A was actually doing

Build A was not slow because the database was slow. It was slow because of the shape of the work:

  • one compile step held 411.1 ms - 91 % of the server window;
  • inside it, a per-rule resolver ran 86 times;
  • each of those read the rule-type catalog: 88 identical SELECT โ€ฆ ORDER BY id in one call.

The response was correct the whole time. Status 200, right data, PASS on content - and a class of defect that no response code can express. It was caught in one call for exactly one reason: the spans carried the query text, so 88 identical statements were visible as 88 identical statements, not as "the database was busy".

A dark JSON card of one span's fields - service, span, duration, status and statement - with the statement SELECT โ€ฆ ORDER BY id highlighted and a note: 88 identical in one call

Three number tiles for build A: 411.1 ms in one compile step, 91 %; 86 resolver calls; 88 identical statements; with a stamp reading status 200 the whole time

The fix moved it to 7 queries. And with the database gone from the picture, the slowest remaining participant became the network - which is the correct end state, not a regression.

The other direction: when the wait has no name

The opposite case, from the write path, same period: recalculation calls started failing on a 20-second deadline - first on 13 of 76 objects, then, after a change meant to help, on 27 of 76.

The instinct was to look at the write transaction. The transaction took 14.8 ms.

The time was in two spans nobody had thought about:

  • queue wait: 64-84 s - work sitting in a queue before anyone started it;
  • one canonicalisation lookup: 32.3 s.

Three bars on one scale: the own write transaction 14.8 ms is a dot, one canonical lookup 32.3 s fills a third, the queue wait 64-84 s fills the row, with a note: 20 s deadline, 13, then 27 of 76 failed

Neither is "the service being slow". Both are the service waiting, and the only reason they were nameable is that somebody had instrumented the wait before it became a problem. The four places a request sits still - acquiring a connection, waiting for a worker, handing work over, waiting for the answer - are almost never covered by a span, which is why they show up as an unexplained gap.

What the traces still do not show

Honest inventory, because a measurement set with unknown holes in it is worse than one with known holes.

Five zone tiles of one call: transport 220-260 ms, hop 0.8-2.4 ms, a dashed red gRPC codec tile marked unknown, service work in its own spans, database per query; a stamp under the codec reads not measured

  • transport, client โ†” environment - measured: steady 220-260 ms, not ours.
  • hop between services - measured as a gap: 0.8-2.4 ms per transition, 7.9 ms on the write call.
  • the service's own work - measured: its own spans, per service.
  • database - measured: per query, with the statement text and a repeat count.
  • gRPC encode / decode - not measured: unknown, probably small, but not a measured zero.

The gap is real: between-service time is derived from where the child span starts, so serialisation, deserialisation and whatever the client library does around them are folded into one number that nobody has split. On the numbers above it cannot be large. That is a bound, not a measurement, and I keep it in the list rather than pretending it is zero.

Why this is an SLO conversation, not a performance one

Once the zones are separated, the chain stops being a mystery and starts being arithmetic:

  chain response โ‰ˆ the slowest participant + everything spent between them
Enter fullscreen mode Exit fullscreen mode

Three number tiles for budgeting in round trips: 53 database round trips, 11 repeated queries, 92 to 7 queries from build A to B, with a note: chain is roughly the slowest participant plus every hop

That reframes every argument we had:

  • a service's target belongs to its own span, because that is the only part its team can move;
  • the door's target is the sum plus the hops, and it is the only number a user experiences;
  • the environment gets an explicit line - 220-260 ms of it here - so it is never mistaken for someone's fault;
  • and the sensible unit for a per-service budget is often not milliseconds but round trips: 53 database calls with 11 repeats, or 92 queries where 7 will do.

None of that needs new infrastructure. It needs the zones to be separable in the data you already collect - and one line in the report saying which zone each number belongs to.

The multiplier line

An automated executor made this cheap: run, pull the trace, fold it into a table, run again on the next build, print the difference. What it did not do was decide what counts as ours. That distinction - our span, the environment, the database, the unmeasured gap - is a judgement, and it is the whole value of the exercise. Speed of measurement amplifies whoever set the definitions; it does not supply them.

A four-panel strip: what we did, 1 256.9 ms pinned on the write service; what broke, 87.4 ms, its own zone, 7 % of it; why, 6.6 ms of work in a 231.8 ms rejection; what now, own span, judge a service by its root


Anatomy of a service split โ€” Part 1. Next: the same run produced numbers nobody could have predicted from the code, because the services had endpoints that could describe their own state. That is the next part - what a service has to expose before anyone can check it.

If you do this better, tell me how you draw the line between your zone and the environment. If you have been through this, what did your unexplained gap turn out to be? If you see it differently, say where per-zone budgets cost more than they return. How is it solved on your side, and what broke there?

Top comments (0)