Development

Setting a Concurrency Cap from Downstream Latency and Errors

Antonio Evans9 min read

A close-up of a dashboard showing p95 latency and error rate plotted against concurrency.
Key points

Key points

  1. 1Set the per-tool concurrency cap so the downstream's utilization stays under the knee where p95 latency and error rate start to rise steeply.
  2. 2Measure p95 service time in milliseconds and error rate at several load steps, then compute the cap as floor(target utilization times 1000 divided by p95 ms).
  3. 3A cap is a limit on in-flight requests, not on requests per second, so its throughput ceiling is cap divided by service time.
  4. 4Recompute the cap whenever p95 service time or the error rate at a given load step shifts, and treat queue wait time as a separate signal from service time.

Read the downstream first, then pick a number

Most concurrency bugs start with a guess. Someone sets a worker pool to 50, a connection pool to 20, or an agent tool to unlimited, and the number survives until an incident. The fix is not a bigger guess. It is to read the downstream service's own signals and derive the cap from them.

Three numbers matter, and you want all three from the same downstream:

  • Service time, reported as a percentile rather than a mean.
  • Error rate at a specific level of load.
  • In-flight count or utilization at that same moment, so you can tell where the knee sits.

Percentiles are the only honest summary of a right-skewed distribution. Averages hide slow requests because a handful of outliers get diluted in the sum, and a service that averages 100 ms can still have a meaningful share of requests taking seconds (Redis on p95). A p95 of 200 ms means 1 in 20 requests took longer, which at production traffic is a lot of real users. For concurrency work, p95 is the workhorse: it describes the common slow path, usually a specific query, cache miss or lock, while p99 tends to catch GC pauses and cold starts (the percentiles guide).

One trap before you measure anything: you cannot average percentiles. Ten instances each reporting a p95 do not average into a valid fleet-wide p95. You need to merge the underlying histograms and then query the percentile (Redis, the percentiles guide). If your dashboard lets you average a p95 line over time or across hosts, it is already lying to you.

Find the knee: the load step where p95 and errors turn up

The knee is the utilization point where a small increase in load produces a large increase in latency or errors. Below it, the curve is nearly flat. Above it, queueing takes over and the curve goes vertical.

You find it by stepping load and recording the downstream's own service time and error rate at each step, not your client's end-to-end time. Keep the steps small enough to see the turn. A table makes the decision explicit.

Load step (concurrent)p95 service timeError rateUtilization (arrival rate x p95 / 1000)Verdict
2200 ms0.0%0.40Flat
4205 ms0.0%0.82Near knee
6340 ms0.4%1.00+Past knee
81,100 ms6%Way overSaturated

The service time in that table is the downstream's own, so the utilization column is a check on arithmetic rather than a claim about a real system. The shape is what matters: from 2 to 4 concurrent requests, p95 barely moves, and from 4 to 6 it jumps by more than half. That is the knee, and it sits somewhere between 4 and 6.

Saturation looks confusing precisely because compute metrics stay quiet. In the classic pool exhaustion incident, p99 latency jumped from 80 ms to 4.2 seconds while CPU sat at 32%, memory was fine and disk I/O was normal (Radview). The bottleneck was circulation, not capacity. This is why you derive the cap from the downstream's latency and error curve and not from its CPU headroom.

Error rate is the second edge of the knee. Some services degrade in latency before they fail; others return 429s or timeouts once a concurrency limit is reached. ICE Mortgage Technology's API documentation states that when a lender's hard concurrency stop is hit, every process stops and a 429 is returned until remaining concurrency is above zero (ICE concurrency limits). Their guidance asks lenders to keep the remaining ratio under 80% and partners under 20% of the allowed concurrency, which is a vendor telling you to stay clearly below the knee.

The cap formula and a worked example

The rule is one line:

cap = floor(target utilization * 1000 / p95 service time in ms)

The 1000 converts the target, a fraction, into milliseconds. The result is a count of in-flight requests. With a 70% target and a p95 of 200 ms, the cap is floor(0.7 * 1000 / 200) = 3 concurrent requests.

Run the example properly. Suppose you measured p95 at 200 ms on the flat part of the curve, the knee sits near 80% utilization, and error rate is zero below it. You want headroom below the knee, so you pick a target of 70%:

cap = floor(0.70 * 1000 / 200) = floor(3.5) = 3

Three concurrent requests is not a typo. It feels small, so check the throughput it implies. With service time of 200 ms and a cap of 3, the ceiling is 3 / 0.2 s = 15 requests per second. If the tool needs 40 requests per second, the cap is not the problem: the downstream is the problem, and you either scale it, cache, or batch. Raising the cap to 8 would push utilization well past 1.0, which in queueing terms means a growing queue, and the queue length and wait time grow with utilization until the pool is exhausted (the pool exhaustion guide).

If your downstream reports utilization directly rather than p95, the equivalent allocation rule from the pool world helps. Heroku's PgBouncer guidance and the shared-capacity formula both push you to treat a connection budget as shared: subtract admin, migration and monitoring reserves from the database's maximum, then divide by the maximum number of concurrent instances (the webalert sizing formula). That gives a per-instance ceiling, and the per-tool cap must fit underneath it.

One more check on top of the formula. Little's Law says the number of requests in the system equals arrival rate times time in system (the Little's Law walkthrough). Your cap times p95 gives the arrival rate the downstream can absorb at that cap. If the needed rate is above it, no concurrency setting saves you.

Measure it: steps that give you the numbers

You need a clean measurement loop, because the cap is only as good as the p95 you feed it.

  1. Fix the window. Record p95 service time per downstream over a fixed interval, from merged histograms, not averaged percentiles.
  2. Step concurrency in small increments and hold each step long enough for the tail to appear. A few hundred requests per step is a starting point.
  3. At each step, capture p95 service time, error rate and in-flight count from the downstream itself, along with any retry counts.
  4. Find the last step where p95 is within a small band of the flat baseline and the error rate is at its floor. The step after that is the knee.
  5. Set the target utilization below the knee. Pick the headroom from the risk in the workload: batch jobs can run closer to the knee than user-facing calls.
  6. Record the numbers and the target in the same place as the cap, so the next person can recompute it.

You can watch the same signals live with pg_stat_activity grouped by state, which separates active, idle and idle-in-transaction connections (the PostgreSQL connection states guide). Idle-in-transaction is the state to watch: those connections hold resources without doing work, and a rising count usually means leaks or long transactions rather than a genuinely busy downstream (the webalert diagnosis steps).

If you are measuring a query rather than an API call, read the plan as well as the timing. EXPLAIN ANALYZE shows actual timings, not just estimates, so you can tell whether a p95 that moves is a plan change or a load effect (Datadog's walkthrough). The timing tells you the p95; the plan tells you whether it will stay put.

For anything calling a metered third-party API, read the concurrency semantics carefully, because they differ. monday.com documents multiple limits including a concurrency limit alongside complexity, daily and minute limits (monday.com rate limits). The Claude API enforces rate limits with a token bucket algorithm, continuously replenishing capacity rather than resetting on fixed boundaries, and can enforce a per-minute limit over a shorter interval, so a short burst can trip it (Claude rate limits). A cap learned from the provider's own concurrency headers beats one learned from a guess.

Apply the cap per tool, not per process

If an agent or worker pool calls several different tools, one global cap will either strangle a fast tool or overwhelm a slow one. The whole point of a per-tool cap is that each downstream has its own service time distribution and its own knee.

Toolp95 service timeTarget utilizationCapThroughput ceiling
Search lookup80 ms0.708100 req/s
Document write200 ms0.70315 req/s
Report generator1,400 ms0.600See note

The last row is the point: at 1,400 ms and a 60% target, floor(0.6 * 1000 / 1400) = 0, and a cap of zero means you need asynchronous handling, not a smaller number. A long-running tool call that blocks the caller freezes the agent, keeps a slot occupied and prevents any other useful work, which is the same shape of problem as holding a database connection across a slow network call (the blocking tool call analysis, the webalert causes list). Submit the job, receive a task identifier, and free the slot.

Scaling the pool across instances also has a multiplier effect that teams miss. Twenty pods each with a pool of 20 demand 400 connections, which can blow past the database's maximum even though each pod looks reasonable (the pool exhaustion guide). Size per-instance caps against the maximum autoscaled instance count, not today's steady state, and remember to reserve admin, migration and monitoring headroom first (the webalert formula).

Finally, be careful with retries. When requests time out waiting for a slot, retries demand more slots from an already loaded downstream, and each retry makes the saturation worse, which is the timeout cascade (How Tech). A cap without a retry budget is half a policy. Retry only when the error is transient, back off, and count retries against the cap.

A checklist before you ship the cap

Use this to review any proposed number before it goes into production.

CheckPass condition
Source of p95Merged histograms from the downstream, not averaged percentiles
Knee locatedA load step where p95 or error rate rises steeply is identified
HeadroomTarget utilization is clearly below the knee, with reasons recorded
Throughput fitsCap / p95 meets the required request rate, or the shortfall is documented
Instance multiplierPer-instance cap times maximum instance count fits the shared budget
Long callsAny tool whose cap rounds to zero is moved to an async path
RetriesRetries are bounded, backed off and counted against the cap
Error signals429s, timeouts and pool wait time are on the dashboard next to p95

The decisions inside that checklist are the real work, and they are where a line-of-business expert earns their keep. A specialist who has run a database pool or an agent platform will read the same table and know whether a 70% target is cautious or reckless for that workload. If your team is building this kind of control loop for an agent or platform, a Quanta expert can take the measurement and sizing work off your hands, and the same skill library installs into a coding agent if you want to run the loop yourself.

When the knee moves, the cap moves

A cap is a snapshot of a downstream at one moment. Service time changes with data volume, index health, plan regressions and traffic mix, and the knee moves with it. Reserve headroom for that movement rather than for today's measurement.

Three signals should trigger a recompute:

  • p95 service time drifting beyond the band you measured at.
  • Error rate rising at a load step that used to be flat.
  • Queue wait time, or connection checkout wait time, rising while service time holds.

The last one is the earliest warning. Pool wait time above roughly 10 ms is a sign the pool is congested, and acquisition failures are the canary that you are close to failure (How Tech). If wait time rises before service time does, your cap is too close to the knee and you should lower the target utilization.

Vendors change their limits too, so owning the number is safer than trusting a published one. The PgBouncer project's release notes describe fixes for authentication bypass and denial-of-service issues across a few versions, including an unauthenticated infinite loop and an unbounded SCRAM iteration count (PgBouncer). Running a pooler inside your own service means you control the cap, the version and the update schedule, and you can read wait time directly, while the pooler still gives you transaction-level reuse and low per-connection memory (PgBouncer features, Heroku's pooling modes).

The closing rule: derive the cap from p95 and measured error-free load, express it as a small integer, verify the implied throughput against demand, and recompute whenever p95 or the error curve moves. That is the difference between a number that survives an incident and a number that causes one.

FAQ

FAQ

What exactly is the knee I am trying to stay below?

The knee is the utilization level where latency or errors stop being flat and start rising steeply, because queueing takes over. You find it by stepping concurrency and recording the downstream's own p95 service time and error rate at each step. The last flat step is your operating point; the next one is the knee. Set the target clearly below it, so a small traffic increase doesn't tip you over.

Why use p95 rather than an average service time?

Latency distributions are right-skewed, with a floor of fast responses and a long tail of slow ones, so the mean is dragged by the tail without showing it (Redis). A p95 tells you the slow-but-common experience, which is the one that consumes concurrency slots. It also gives a defensible number to plug into the formula, because it describes a real threshold rather than a blurred mean.

What if the formula gives me a cap of zero?

A cap of zero means the target utilization times 1000 is smaller than the service time in milliseconds, which happens with long calls. The answer is not a fractional cap; it is an asynchronous path. Submit the job, take a task identifier, and free the slot until the result arrives. Blocking a slot for a multi-second call stalls the caller and prevents other work (the blocking tool call analysis).

How do I handle a downstream that enforces a hard concurrency limit I cannot see?

Treat the vendor's published limit as the ceiling and derive your target below it, as ICE does with an 80% remaining ratio for lenders and 20% for partners (ICE). Where headers expose remaining concurrency, meter against them proactively rather than waiting for a 429. Back off exponentially on any throttle response, and treat sustained 429s as a sign your measurement is stale.

Does a higher cap always mean more throughput?

No. Throughput ceiling is cap divided by service time, and that only holds while utilization stays below the knee. Push the cap past the knee and queueing time grows, so effective service time rises and throughput falls while errors climb. The cheap route to more throughput is a shorter p95 through indexing, caching or batching, not a bigger cap (Radview on fix order).

Sources

Sources

  1. P95 Latency: What It Is & Why It Matters
  2. Performance Percentiles: p50, p95, p99 Explained (2026)
  3. Connection Pool: Detect & Fix Issues Fast
  4. Concurrency Limits - Encompass Developer Connect
  5. Connection Pool Exhaustion: The Silent Killer
  6. Connection Pool Exhaustion: Causes, Diagnosis & Fixes
  7. DB Connection Pool Exhaustion: The Silent Killer Behind ...
  8. PostgreSQL Connection States: Types, Monitoring &
  9. Debug PostgreSQL query latency faster with EXPLAIN ANALYZE in ...
  10. Rate limits
  11. Rate limits - Claude Platform Docs
  12. Why blocking long-running tool calls breaks agent work
  13. PgBouncer - lightweight connection pooler for PostgreSQL
  14. PgBouncer features
  15. PgBouncer Configuration and Best Practices - Heroku Dev Center
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles