Latencyconcurrencyadmission controlload sheddinglimitsbackpressure

Concurrency Limits: An Unbounded Server Is a Slower Server

Accepting every request that arrives feels generous and produces the worst possible outcome: everything is slow, everything times out, and the capacity is spent on work nobody is still waiting for. A limit is a latency control, not just a safety valve.

Follow the diagnosis

Frame the diagnosis

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

Diagnostic question
How much work should this service allow in flight at once — and what should happen to the requests that arrive beyond that?
Symptom
Under a traffic spike the service does not reject anything, but every request takes 30 seconds and nearly all of them time out. Throughput measured in *useful* responses drops close to zero.
Signal
The **ratio of completed-and-delivered responses to accepted requests** confirms it: work is being done and thrown away. **Accepted request rate** is the misleading signal — it looks like the service is coping right up until nothing useful comes out.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

What unbounded acceptance actually buys

A server with no concurrency limit does not serve more requests; it serves the same number more slowly, and spends the difference on requests that will be abandoned. The arithmetic is unforgiving: if capacity is 500 concurrent requests worth of work and 5,000 arrive, each of the 5,000 gets roughly a tenth of the service rate. Every one of them crosses the client timeout. Zero useful responses are delivered, and the machine was busy the entire time.

Bound the same system at 500 and the outcome inverts: 500 requests are served at normal latency and succeed, while 4,500 receive an immediate rejection. Nine out of ten users get an error instead of a timeout — but one in ten gets a working product, and, crucially, the error arrives in milliseconds rather than after a 30-second wait. Fast failure also lets clients fail over, retry elsewhere, or degrade gracefully; a slow failure denies them that option too.

This is the counter-intuitive core of admission control: rejecting work is how you preserve the ability to do work. It is the same insight as Backpressure viewed from the server's side, and the reason Queueing: Why Systems Get Slow Before They Get Broken warns against solving overload with a bigger queue.

Unbounded: accept everything, deliver nothing
1capacity ~500 concurrent requests
2arrivals 5,000 concurrent
3limit noneevery request accepted
4
5result:
6 in flight 5,000
7 service rate/req ~1/10th of normal
8 p99 latency 32 s
9 client timeout 5 s
10 useful responses ~0 <-- all work discarded
11 CPU 100% busy the whole time
12
13# The machine worked as hard as it could and produced nothing.
Bounded: reject fast, serve the rest properly
1capacity ~500 concurrent requests
2arrivals 5,000 concurrent
3limit 500 in flight, reject beyond
4
5result:
6 in flight 500
7 service rate/req normal
8 p99 latency 240 ms
9 rejections 4,500 (immediate 503 + Retry-After)
10 useful responses ~500/interval, all delivered
11 CPU 100% busy doing work that lands
12
13# Same hardware. Same load. The limit is the only difference.

Nothing about the capacity changed — only what happens to work beyond it. Unbounded acceptance converts a capacity problem into a total outage; a limit converts it into partial, fast, honest degradation that clients can respond to.

Choosing the number

The starting point is Little's Law: the concurrency you can support at your target latency is throughput × target_latency (Little's Law as Working Intuition). If the service handles 2,000 rps at a 100 ms target, that is 200 concurrent requests. Measure rather than assume the throughput term — it is the capacity at the latency objective, not the peak from a load test (Throughput: The Number That Means Nothing Without a Latency Bound).

Static limits are simple and adequate for many services, but they are wrong whenever capacity changes: a slower dependency reduces effective capacity, so a limit that was right yesterday admits too much today. Adaptive schemes address this by inferring the limit from observed latency — increasing it while latency stays flat and decreasing it when latency rises — which is conceptually the same control loop that TCP congestion control runs on a network path (Congestion Control: Protecting the Network).

Whichever you choose, the limit belongs at more than one level. A global limit protects the process; per-dependency limits stop one slow downstream from consuming every worker; per-tenant limits stop one customer from crowding out the rest (Rate Limiting). A single global limit lets one saturated dependency absorb the entire budget while everything else starves.

Where to bound, and what each bound protects against
LevelBoundsProtects againstCost of omitting it
Global in-flightTotal concurrent requests in the processOverload turning into total collapseEverything slow, nothing delivered
Per-dependencyConcurrent calls to one downstreamOne slow dependency consuming all workersA degraded downstream takes the whole service with it
Per-tenant / per-keyConcurrency or rate per callerOne customer crowding out all othersA single client's burst is everyone's outage
Per-endpoint classExpensive operations separately from cheap onesSlow exports starving fast readsOne heavy endpoint sets the latency for all of them
Queue boundHow much may wait, not just how much runsUnbounded waiting that guarantees timeoutsWork accepted that can never be served in time

Shedding well

Once you shed, *what* you shed becomes a design decision. Uniform random rejection is the simple default and treats all work as equally valuable, which it rarely is. Prioritised shedding — dropping background refreshes and prefetches before interactive requests, or protecting checkout while degrading recommendations — preserves far more user value at the same capacity. This requires the request to carry enough information to classify it, which is a contract question as much as a runtime one (API Performance: The Levers You Actually Own).

The rejection itself should be a good citizen: a clear status, a Retry-After that spreads returning clients rather than synchronising them, and a response cheap enough that shedding does not itself become expensive. A rejection path that allocates, logs verbosely and serialises a large error body can consume the very capacity it was meant to protect.

Finally, deadline propagation makes shedding smarter: if a request arrives already carrying a deadline that has nearly expired, the correct action is to reject it immediately rather than start work that cannot finish in time. This is the same principle as not serving abandoned work in Queueing: Why Systems Get Slow Before They Get Broken, applied at admission rather than at completion (Timeouts: The Latency Contract Nobody Writes Down).

  • Shed by priority, not at random — background and prefetch traffic before interactive requests.
  • Make rejection cheap — a costly error path spends the capacity that shedding was protecting.
  • Return `Retry-After` with jitter so rejected clients do not return in a synchronised wave (Retry Storms: The Load You Generated Yourself).
  • Reject on expired deadlines at admission rather than starting work that cannot land in time.
  • Distinguish shed from failed in metrics — deliberate rejection and genuine errors need separate lines, or your error-rate SLI becomes unreadable (SLIs: Measuring What the User Actually Feels).

Key points

  • An unbounded server under overload does the same amount of work and delivers almost none of it, because everything crosses the client timeout.
  • A concurrency limit is a latency control: it decides how many requests share capacity, and therefore how fast each is served.
  • Size the limit from throughput × target_latency, using capacity at the latency objective rather than peak throughput.
  • Bound at several levels — global, per-dependency, per-tenant, per-endpoint-class — because one global limit lets a single slow dependency absorb everything.
  • Shed by priority, make rejection cheap, add jitter to Retry-After, and count shed traffic separately from errors.

Follow the diagnosis

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

  1. 1
    Spike → service: arrivals jump to roughly ten times sustainable concurrency; no limit exists so all are accepted.
  2. 2
    Acceptance → sharing: capacity is divided across ten times as many in-flight requests, so each proceeds at roughly a tenth of normal speed.
  3. 3
    Sharing → timeouts: every request exceeds the 5 s client timeout; clients disconnect while the server continues processing.
  4. 4
    Timeouts → waste: completed responses are written to closed connections; useful throughput approaches zero while CPU stays pinned.
  5. 5
    Root cause → team: absence of an admission bound converted a partial-capacity event into a complete outage.
What this evidence makes people conclude — wrongly
  • "We are not rejecting anything, so we are handling the load" — accepting is not serving; measure responses delivered before the client gave up.
  • "CPU is at 100%, so we are at capacity and doing our best" — the machine is busy producing responses nobody receives.
  • "Rejecting requests will make the outage worse" — it converts a total outage into partial service, which is strictly better for users and for recovery.
  • "One global limit is enough" — a single slow dependency will consume the entire global budget while every other endpoint starves.
  • "Shedding is an error, so it belongs in the error rate" — mixing deliberate rejection into the error SLI makes both numbers uninterpretable.

Measure, fix, validate

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

How to measure it
  • • Track accepted, completed, and *delivered-before-client-timeout* separately — the gap between the last two is wasted capacity.
  • • Record in-flight concurrency as a gauge against the configured limit, so time spent at the bound is visible ([[littles-law]]).
  • • Measure rejection rate and reason, split from genuine error rate.
  • • Instrument per-dependency concurrency to see whether one downstream is consuming a disproportionate share of workers.
  • • Track deadline-expired-at-admission counts, which quantify work you correctly refused to start.
What actually fixes it
  • • Add a global in-flight limit sized from `throughput × target_latency`, with fast rejection beyond it.
  • • Add per-dependency limits so one degraded downstream cannot occupy every worker ([[circuit-breaker]]).
  • • Bound the wait queue as well as the running set, so work that cannot be served in time is never admitted.
  • • Implement priority-aware shedding and deadline checks at admission.
  • • Consider an adaptive limit that infers capacity from latency, for services whose effective capacity varies with dependency health.
How you know it worked
  • • Re-run the overload test and confirm delivered-before-timeout throughput is now substantially above zero, and p99 for admitted requests is near baseline.
  • • Verify rejections appear as rejections in telemetry and are excluded from the error SLI.
  • • Check that per-dependency limits engage during a simulated slow dependency and that unrelated endpoints stay healthy.
  • • Confirm the rejection path itself is cheap under load, by measuring CPU cost per rejection.
What it costs
  • • Shedding means some users see failures during overload; that is a deliberate product trade, and it needs to be an explicit decision rather than an accident.
  • • Limits set too low waste capacity and reject work the system could have served — under-utilization is a real cost.
  • • Adaptive limits add a control loop that can oscillate or mis-infer capacity, and are harder to reason about during an incident than a static number.
  • • Multi-level limits multiply configuration and the number of ways a request can be refused, which complicates debugging.
Stop it coming back
  • Alert on sustained time-at-limit, which indicates the limit is now binding routinely and capacity planning needs revisiting.
  • Add an overload scenario to load testing that asserts non-zero useful throughput and bounded latency for admitted work (Load Test Shapes: The Shape Is the Hypothesis).
  • Review the limit whenever dependency latency changes materially, since effective capacity moves with it.

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • ILLUSTRATIVEThe bounded/unbounded comparison uses invented numbers chosen to make the mechanism legible. The qualitative outcome — unbounded overload delivering near-zero useful throughput — is well established; the specific latencies are not measurements.
  • WORKLOAD-SPECIFICThe right limit depends on service-time distribution, dependency latency and the client timeout. A limit derived for one traffic mix can be badly wrong for another.

Misconceptions

Claim
“Rejecting requests during overload makes the outage worse.”
Reality
It makes it partial instead of total. Without a limit, capacity is spread so thin that nothing completes within the client timeout; with one, a subset of users get a working service and the rest get a fast, actionable failure.
Claim
“A concurrency limit is only a safety mechanism for emergencies.”
Reality
It is a latency control in normal operation too. The limit determines how many requests share capacity, which directly determines per-request service rate and therefore the latency distribution.
Claim
“If the server is at 100% CPU it is doing all it can.”
Reality
It may be doing an enormous amount of work whose results are discarded because the requesters have already timed out. Busy is not the same as productive; measure delivered responses, not utilization.

Apply it

Where the depth lives

Control theory
Feedback control of admission

Adaptive concurrency limits are a controller: latency is the measured output, the limit is the actuator, and the loop must be damped to avoid oscillation. The same stability concerns that apply to any feedback controller apply here, which is why aggressive adaptation can be worse than a static bound.