Ground Truth

Slow at Scale, Worse When Scaled: It Is Usually a Query-Count Problem

Mostafa DhouibMostafa Dhouib·8 min read
The short answer

When a product is slow under load and adding servers makes the economics worse without fixing the latency, the bottleneck is the work inside one request rather than the capacity to run many. Profile a single request before provisioning anything. On a flight-search product this took the median from 12.8 to 2.8 seconds and database queries per search from 149 to 38, on the same hardware, at roughly a third of the infrastructure cost per search.

Slow at Scale, Worse When Scaled: It Is Usually a Query-Count Problem

The short answer. Capacity fixes a queue. It does not fix work. If each request does far too much, every server you add pays for that work again, so the bill scales and the latency does not. Profile one request end to end before provisioning anything, count the queries and the redundant outbound calls, and remove them. On a flight-search product that took the median search from 12.8 seconds to 2.8, the ninety-fifth percentile from 29.6 to 7.5, the database queries per search from 149 to 38, and the infrastructure cost per search from 9.5 cents to 2.9, on the same hardware.

There is a particular kind of slow that misleads everybody, and it has a tell.

The product works. Nothing errors. It is just slow, and when you add capacity it does not get meaningfully faster, and the bill goes up in a way that feels disproportionate. Sometimes it even gets worse.

That combination is close to diagnostic. Slow plus unresponsive to capacity plus expensive means the problem is inside one request, not across many of them.

What capacity actually buys

It is worth being exact about this, because the reflex to scale is not stupid, it is just aimed at a different problem.

Adding servers increases how many requests you can handle at once. If requests are arriving faster than you can serve them, they queue, and queueing time shows up as latency. More capacity drains the queue, the queueing time falls, and latency improves. That is a real mechanism and it works.

But it only addresses queueing time. The time a request spends doing its own work is unchanged by the number of machines available to do it. If one search takes eleven seconds of actual work, it takes eleven seconds of actual work on a cluster of fifty machines, and you are now paying fifty times over for the privilege.

So the question to ask before provisioning is simply: how much of this latency is waiting, and how much is working? If the system is slow even when it is not busy, it is working, and capacity is the wrong instrument.

The gate
Time a single request on an unloaded system. Is it already slow?
You have work, not waiting
nothing is queueing, so no amount of capacity helps. Every server you add pays for that work again, which is why the bill scales and the latency does not.
You have genuine queueing
requests are arriving faster than you serve them, queueing time is showing up as latency, and capacity is a reasonable instrument.
Slow, unresponsive to capacity, and expensive is close to diagnostic. The problem is inside one request rather than across many of them.
FigureCapacity fixes a queue and it does not fix work. More machines reduce queueing time, and the time a request spends doing its own work is unchanged by how many machines are available to do it. One measurement separates the two cases, and it is the measurement that decides whether provisioning is the right instrument at all.

Count the queries

The single most useful number in this kind of diagnosis is the query count for one request. Not the average query time, not the slow query log, the count.

Slow query logs are good at finding the one query that takes four seconds. They are structurally bad at finding a hundred and forty-nine queries that each take eight milliseconds, because none of them is slow. Each one is individually fine. Together they are the whole problem.

On the flight-search product the count was 149 per search. That is not a number anybody designed. It is a number that accumulates: a loop that looks up a related record per iteration, a helper that fetches a configuration row every time it is called, a serialisation step that resolves a reference per field. Each of those is reasonable in isolation and invisible in code review, because the code that causes it does not look like a database call.

Batching and caching took it to 38.

That pattern has a name in most ecosystems, usually some variant of the N+1 problem, and the name is less useful than the measurement. You do not find it by knowing about it. You find it by counting.

Three things a profile tends to find

Repeated reads of the same data. The same configuration, the same lookup table, the same user record, fetched many times within one request because the code paths that need it do not know about each other. The fix is request-scoped caching, and it is usually the largest single win.

Lookups inside loops. One query per item, where one query for all items would do. The fix is batching, and the hard part is noticing, because the loop body usually calls something that calls something that queries.

Serial calls that could be concurrent. Where a request makes several independent outbound calls one after another, total latency is the sum. Run them concurrently, with a bound, and it approaches the slowest one instead. On the flight-search product the provider fan-out was serialised, and parallelising it with a concurrency ceiling was a large part of the tail improvement, from 29.6 seconds at the ninety-fifth percentile to 7.5.

Keep the bound. Unbounded concurrency replaces a latency problem with a rate-limit problem or a connection-pool problem, and those are harder to see.

Measure cost per request alongside latency

This is the part most performance work skips, and it is what makes the result defensible.

Track infrastructure cost per request as a first-class number next to latency. On this engagement it went from 9.5 cents per search to 2.9. That number does two things.

It proves the work was not a trade. A great deal of performance work buys latency with money, by adding caching tiers, bigger instances or more replicas. That is sometimes right, but it should be a stated decision rather than a side effect nobody priced. When latency and cost per request both improve, the work removed waste rather than relocating it.

It also makes the argument for not scaling. "We can make it four times faster for a third of the cost" is a different conversation from "we can make it faster", and it is the conversation that stops someone provisioning a larger cluster next quarter.

Before, on the flight search
Database queries per search
149
a number nobody designed, accumulated from a lookup per loop iteration, a config row per helper call, a reference resolved per field during serialisation
Median search
12.8 seconds
Ninety-fifth percentile
29.6 seconds
the provider fan-out was serialised, so total latency was the sum
Infrastructure cost per search
9.5 cents
After, on the same hardware
Database queries per search
38
request-scoped caching for the repeated reads, batching for the lookups inside loops
Median search
2.8 seconds
Ninety-fifth percentile
7.5 seconds
the fan-out parallelised with a concurrency ceiling, because unbounded concurrency trades a latency problem for a rate-limit one
Infrastructure cost per search
2.9 cents
latency and cost both falling is what proves the work removed waste rather than relocating it
count, then remove
This expires if you let it. The queries came back one at a time through ordinary feature work and they will do it again, so assert the count on the main request paths and let it fail the first time one line reintroduces the pattern.
FigureThe useful number is the query count for one request, not the average query time and not the slow query log. A hundred and forty-nine queries at eight milliseconds each will not appear in any slow query log, because none of them is slow. Each one is individually fine and together they are the whole problem.

Why this work expires if you let it

Removing work is not a permanent change in the way raising a limit is. The queries came back one at a time, through ordinary feature work, and they will do it again. Every new feature that adds a lookup inside a loop puts a few back.

So the count needs a guard. A test that asserts the query count for the main request paths is the cheapest form of this, and it fails loudly the first time a change reintroduces the pattern, at the point where it is one line to fix rather than a quarter of archaeology later.

Without that, the honest statement about this kind of work is that it holds exactly as long as the work stays removed.

Profile the real mix

One caution on generalising from a profile. The numbers above were measured on the actual query mix the product served. A substantially different request shape, in that case far more candidate hubs or much longer date ranges, has its own profile and its own hot spots.

This is not a reason to skip profiling. It is a reason to profile the thing you actually serve rather than a synthetic benchmark that exercises a shape nobody requests.

The order of operations

Profile one request end to end. Count the queries and the outbound calls. Remove the repeated reads, batch the loops, parallelise what is independent with a bound. Measure latency and cost per request together. Put a guard on the count so it stays removed.

Then, if it is still slow when it is busy, scale what is left. By that point you are scaling something worth scaling.

FAQ

Why did adding servers make my slow application worse instead of better? Because capacity only reduces queueing time, and your latency was work time. Every additional server pays the per-request cost again, so the bill scales with the waste while the latency barely moves. The tell is a system that is slow even when it is not busy, which means requests are not waiting for a machine, they are working.

How do I tell whether I have a capacity problem or a per-request work problem? Measure latency when the system is idle. If a single request on an unloaded system is already slow, no amount of capacity will help, because there is nothing queueing. If it is fast when idle and slow under load, you have genuine queueing and capacity is a reasonable instrument.

Why doesn't the slow query log find this? Because the problem is usually not one slow query, it is a large number of fast ones. A hundred and forty-nine queries at eight milliseconds each will not appear in any slow query log, and each one looks reasonable on its own. Count the queries per request instead, which is the measurement that makes the pattern visible.

What causes a request to make a hundred-plus database queries? Three things, usually together: the same data read repeatedly within one request by code paths that do not know about each other, a lookup executed once per item inside a loop, and reference resolution during serialisation. None of them look like database calls at the call site, which is why they survive code review and only show up in a profile.

Should I measure infrastructure cost per request as well as latency? Yes, because it distinguishes removing waste from buying latency with money. If latency improves and cost per request also falls, the work removed work. If latency improves and cost rises, you relocated the problem, which may still be the right call but should be a decision rather than a surprise.

How do I stop the query count creeping back up? Assert it. A test that pins the query count for the main request paths fails the first time ordinary feature work reintroduces a lookup inside a loop, when it costs one line to fix. Performance won by removing work is not durable the way a raised limit is. It holds only as long as the work stays removed.

Free checklist
The Scale Readiness Check

Six sections that find the failures with no small-scale version: per-target limits and the feedback loop they cause, absence detection, the heterogeneity inventory, a load ramp that finds the knee rather than confirming today is fine, the degradation ladder, and cost per unit your customer buys.

One email, the resource, and nothing else unless you reply.
Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program