Rate limiting is one of the first protections we add to an API.
A client gets 100 requests per minute. A tenant gets 1,000. Maybe expensive endpoints get tighter limits. When someone exceeds the allowance, the request gets rejected.
That solves an important problem.
But it doesn't answer another question:
When capacity becomes scarce, which work should actually be allowed to consume it?
That's where admission control becomes useful.
The distinction matters increasingly for LLM workloads, where two requests can have radically different costs even though they both count as one request.
Rate limiting controls arrival rate
At a high level, a rate limiter answers something like:
How much traffic may this caller send during a period of time?
A token bucket might allow 100 requests per minute with some burst capacity.
That can protect against:
- abusive clients
- accidental retry storms
- noisy tenants
- sudden traffic spikes
- exceeding contractual quotas
- excessive API consumption
HTTP even has a status code associated with this case. RFC 6585 defines 429 Too Many Requests as indicating that a user has sent too many requests in a given amount of time.
Rate limiting is extremely useful.
But rate is only one dimension of resource pressure.
A request count isn't a resource model
Imagine an LLM service with a limit of 100 requests per minute.
Now consider two workloads.
Interactive requests:
input: 1,000 tokens
max output: 300 tokens
latency: user is waiting
Background requests:
input: 30,000 tokens
max output: 4,000 tokens
latency: nobody is waiting
Both requests count as:
1 request
But they aren't equivalent from the perspective of the underlying system.
They may occupy provider concurrency for different lengths of time. They may consume very different token budgets. They may have completely different latency requirements.
A rate limiter cannot see that if its unit of accounting is simply requests per second or requests per minute.
The traffic can therefore remain completely within its configured rate limit while still exhausting a scarce resource.
Concurrency limits help, but introduce another problem
Suppose we add a concurrency limit:
maximum in-flight requests = 20
Now the application can't overwhelm the downstream service with unlimited parallel work.
That's an improvement.
But imagine 20 background requests acquire all 20 slots.
One second later, an interactive request arrives from a user waiting for an answer.
The rate limiter says:
allowed
The concurrency limiter says:
no capacity
The system is protected, but it hasn't necessarily protected the work that matters most.
We have moved from a rate problem to an allocation problem.
Admission control asks a different question
In this article, I'm using admission control in the narrower capacity-aware sense.
Terminology isn't universal. Some systems use "admission control" broadly enough to include rate limiting itself. The useful distinction here is between controlling how quickly traffic may arrive and deciding whether a particular request should consume currently scarce execution capacity.
Under that definition, admission control asks:
Given the capacity available right now, should this specific piece of work be allowed to start?
That decision can incorporate more information than a conventional rate limiter:
current concurrency
current resource utilization
estimated request cost
request priority
tenant
workload class
reserved capacity
queue depth
deadlines
Instead of merely counting arrivals, we're deciding how scarce capacity should be allocated.
Consider interactive and batch traffic
Suppose a service has 32 execution slots.
Two workload classes share them:
interactive
batch
Without additional controls, batch processing may consume all 32 slots.
A concurrency limiter still prevents the system from exceeding 32 requests, but interactive traffic now waits behind work that nobody is waiting for.
One alternative is to statically divide capacity:
interactive: 28 slots
batch: 4 slots
That protects interactive traffic, but it can waste capacity.
If only 10 interactive requests are running, 18 interactive slots sit idle while batch work waits.
A more flexible admission policy could instead say:
Interactive traffic has protected capacity.
Batch traffic may borrow unused capacity.
When interactive demand increases, new batch admissions stop
until protected capacity is restored.
Now the system can simultaneously pursue two goals:
- keep expensive infrastructure utilized when capacity is available
- protect latency-sensitive work when contention appears
A request-per-minute limit alone cannot express that policy.
LLM workloads make the distinction more obvious
This problem exists in ordinary distributed systems, but LLM APIs make it particularly visible.
Request cost varies dramatically.
A request containing a short chat message isn't equivalent to a request asking a model to process a large document with a large maximum output budget.
So an LLM admission controller might track both concurrency and an approximate in-flight token budget.
For example:
request A:
input estimate = 1,200
max output = 400
reserved budget = 1,600
request B:
input estimate = 24,000
max output = 3,000
reserved budget = 27,000
Now, the admission decision can consider resource pressure rather than only the request count.
The reservation doesn't even need to perfectly predict final token usage to be useful.
It can reserve conservatively at admission and reconcile the reservation once actual usage is known.
That turns admission into a resource-allocation problem rather than simply a traffic-counting problem.
Rate limiting can also miss overload that has already started
There is another important difference.
A rate limit generally represents a policy about incoming traffic:
tenant A may send 50 requests/second
But the safe rate of a distributed system isn't necessarily constant.
Maybe a downstream provider has slowed down.
Requests that normally complete in 500 ms now take 8 seconds.
Even if the arrival rate hasn't changed, concurrency begins accumulating:
arrival rate stays constant
↓
requests take longer
↓
in-flight work grows
↓
queues grow
↓
latency rises
↓
timeouts trigger retries
↓
even more work arrives
Google's SRE guidance explicitly warns that simple rate limiting may not account for overall service health and therefore may not stop a failure that has already begun. It recommends rejecting work as systems approach overload and shedding load before resource exhaustion produces cascading failures.
This is a different failure mode from a client merely sending too many requests.
They belong together
The lesson isn't:
Replace rate limiting with admission control.
It's:
Use each mechanism for the failure mode it is good at controlling.
A production path might look roughly like this:
request
│
▼
authentication
│
▼
rate limit / quota
│
▼
admission control
│
▼
downstream service
Conceptually:
if (!rateLimiter.allow(tenant)) {
return tooManyRequests();
}
const reservation = admissionController.tryAcquire({
workloadClass: request.workloadClass,
estimatedCost: estimateCost(request),
});
if (!reservation) {
return overloaded();
}
try {
return await callDownstream(request);
} finally {
reservation.release();
}
The rate limiter protects the service from traffic policy violations.
The admission controller protects scarce execution capacity.
Those aren't identical jobs.
Different failure modes, different questions
I find it useful to frame the difference this way.
Rate limiting asks:
How much traffic may this caller send?
Concurrency limiting asks:
How much work may execute simultaneously?
Admission control asks:
Which work should consume scarce capacity right now?
Load shedding asks:
Which work should we stop accepting because the system is approaching overload?
These mechanisms overlap, and real systems frequently combine them. The boundaries aren't perfectly clean.
But the questions they answer are different enough that treating all of them as "rate limiting" can hide important design decisions.
Why this matters for agents
Agentic systems make the allocation problem even more interesting.
A single user action can create multiple downstream model calls.
Background agents may execute continuously.
Retries can multiply requests.
Tool calls may produce additional model calls.
Long-context operations can consume much more capacity than short interactive requests.
So eventually the question stops being:
How many requests per minute should we allow?
and becomes:
When demand exceeds available capacity,
which work gets to continue?
That is a scheduling and resource-allocation question.
Rate limiting alone doesn't answer it.
The bigger reliability lesson
Overload doesn't always look like a crash.
Sometimes every component remains technically healthy while the wrong work consumes the available capacity.
Queues grow.
Interactive requests wait behind batch jobs.
Retries increase pressure.
Latency explodes.
Eventually, users experience a failure even though the system is still processing requests exactly as designed.
Reliable systems, therefore, need more than a maximum request rate.
They need a policy for scarcity.
That is the problem admission control is trying to solve.
I've been exploring this problem while building async-bulkhead-llm and MoFlux, particularly around token-aware admission and protecting interactive traffic while allowing lower-priority workloads to use otherwise idle capacity.
The deeper I get into the problem, the more useful this distinction becomes:
Rate limiting controls how much traffic arrives. Admission control decides which work deserves scarce capacity when it does.
Top comments (7)
The borrow rule gates future admissions only. "Interactive traffic has protected capacity. Batch traffic may borrow unused capacity. When interactive demand increases, new batch admissions stop until protected capacity is restored" says nothing about the batch work already holding borrowed slots, which keeps them until it finishes on its own. So restoration time is not a property of the policy at all. It is the completion-time distribution of whatever happens to be in flight when demand rises, and with the article's own figures that is a wide distribution: batch asks for 4,000 max output tokens against interactive's 300, generation time scales with output length, and the interactive request waits for the last of the borrowed slots it needs rather than the average one.
So "interactive has protected capacity" holds in steady state and weakens during the exact transition the rule exists to handle. Fixing it means pricing the loan. Either borrowed slots become revocable, which for a streaming completion means canceling in-flight batch generation and accepting the discarded partial output as the cost, or the amount lent out gets capped by how long it stays unrecoverable instead of by how many slots are currently free. A batch request with a 4,000-token output budget is a longer loan than one with 500, even though both occupy one slot.
The reservation accounting has the same timing shape. Reservation is input estimate plus max output, so request A holds 1,600 and request B holds 27,000. Real completions usually stop well short of max, which makes the error one-sided: the controller holds more budget than the work turns out to need, and that surplus is capacity being refused to someone. Reconciling once actual usage is known fixes the ledger, but it happens at completion, and completion is also when
reservation.release()in thefinallyblock hands the whole reservation back regardless. The correction lands when there is no admission decision left for it to change.To reach admission, the reservation has to shrink while the request is still running. A streaming completion reveals output tokens as they are produced, so the unused part of the max-output allowance can be released continuously, and a request that stops at 400 of a 3,000-token budget frees the other 2,600 long before it returns. Reserving against max output is a bound on what a request may consume. Admission is asking what it is consuming right now, and those are different quantities every time the model stops early.
This is a useful distinction, and I agree with part of it.
The borrow rule prevents new batch admissions once interactive demand returns, but it does not instantly reclaim capacity already borrowed by in-flight batch work. Without preemption, restoration latency is bounded by the lifetime of those borrowed admissions. So “protected capacity” is not the same thing as “immediately recoverable capacity.” That is worth stating more precisely.
Where I disagree is the token-reservation conclusion.
Streaming tells you how many output tokens have been consumed so far. It does not yet tell you that the remainder of
maxOutputis unused capacity.If a request has already generated 400 tokens with
maxOutput = 3,000, it can still legally generate another 2,600 tokens. Releasing those 2,600 for another admission would mean the controller has stopped reserving against the request’s possible future consumption. If both requests consume their remaining budgets, the controller can exceed the capacity invariant.You can safely reconcile things that become known during execution, such as replacing an estimated input-token count with the provider’s actual count. But the unused output reservation is generally only known when the model stops, unless the system can also throttle, pause, or preempt an already-running generation.
So I think there are actually two separate design questions here:
The second approach can certainly be built, but it is a different invariant. If admission is based on current consumption rather than remaining possible consumption, the controller needs a mechanism for the case where already-admitted streams continue growing into capacity that has since been reallocated elsewhere.
You are right about the invariant, and I overstated the release.
Streaming reports consumption so far. It does not retire the remaining allowance, and handing 2,600 tokens to a new admission while the first request is still entitled to them puts two claims on the same capacity. That is precisely the thing admission control exists to prevent.
The release only becomes safe if it carries enforcement with it. Selling back the unused part of maxOutput has to be a commitment to stop the stream at the new boundary: the controller lowers that request's ceiling from 3,000 to, say, 800, and keeps the right to abort generation if it gets there. The controller has then re-issued a smaller bound rather than admitting against observed consumption, and the invariant survives because every in-flight request still has a hard ceiling the controller can name. Absent that abort right, your objection stands and there is no way around it.
Which folds your two questions into one. Reclaiming borrowed concurrency and shrinking an outstanding token reservation are the same operation at different granularity. Both require revoking something already granted. If nothing is revocable, every admission is a loan on worst-case terms, and utilization is capped by the ratio of worst case to typical. A 3,000-token ceiling against a 400-token median completion is a 7x gap, and you either hold that as headroom you refuse to sell or you build preemption.
Batch is the right class to preempt because it is the one where the cost of an abort is bounded and knowable. A discarded partial batch generation costs the tokens already billed plus a retry, the work is idempotent, and nothing is blocking on it. Aborting an interactive stream costs a visible failure to whoever is waiting. The asymmetry that makes batch a safe borrower is the same asymmetry that makes it a safe thing to reclaim from. A policy that lets batch borrow but never lets the controller take it back is using half of that argument.
I think the key distinction is between borrowing idle capacity and guaranteeing immediate reclamation of that capacity.
I agree that if the controller wants to shrink an already-granted token ceiling or reclaim an in-flight concurrency slot immediately, it needs some form of revocation or preemption. Otherwise, the original admission must still be honored.
Where I’m less convinced is that this makes preemption a requirement for borrowing itself. A non-preemptive policy can still let batch work use idle capacity, then stop admitting new batch work when protected demand returns. The tradeoff is simply that restoration is bounded by the lifetime of already-admitted batch work rather than being instantaneous.
So I’d separate the two properties: borrowing improves utilization, while preemption improves reclamation latency. They can be combined, but I don’t think one necessarily implies the other.
Agreed, and I'll drop the stronger claim. Borrowing doesn't require preemption, and a non-preemptive borrow is a legitimate point on the curve.
Where I'd push is the second half of your framing. "Preemption improves reclamation latency" reads like a performance knob. Without preemption, reclamation latency doesn't just get worse. It becomes a quantity nobody has written down. The protected class was sold capacity when it needs it. What it actually holds is capacity once the in-flight borrowers drain, and if that number never appears next to the policy then the borrow is unpriced.
The good news for your design is that the bound already exists. Your background profile caps max output at 4,000 against 300 for interactive, and that cap is enforced. So worst-case restoration is the completion of the longest in-flight borrowed request, bounded above by 4,000 tokens of generation. That's a real number and it belongs in the policy statement rather than the implementation notes. Once it's stated you get a third option between preempting and waiting: admit with an enforced deadline. Abort then stops being a policy and becomes what happens when the deadline is missed, which is the same mechanism doing considerably less work.
One catch, and I think it carries the weight here.
max_outputbounds tokens. Restoration latency is wall time. Tokens divided by rate. Rate is the thing that degrades exactly when protected demand returns, since that's when the accelerator is contended. So the bound is denominated in a unit that inflates under precisely the condition it exists to cover. Measure restoration on an idle system and you get one number. The number you actually need comes from a saturated system and is larger by however much the returning load slowed generation.Which argues for the deadline being in wall-clock seconds with the token cap as a secondary guard. Reclamation time then gets bounded by something the controller sets instead of something the borrowed workload's throughput happens to produce.
I think this is the right refinement.
If borrowing is part of the policy, restoration behavior should be part of the policy too. Otherwise “protected capacity” really means “capacity that returns once borrowed work drains,” and that is a materially weaker guarantee than the name suggests.
I also agree that
max_outputgives us a bound on remaining work, not on restoration time. Under saturation, token throughput can degrade, so a token-denominated bound is weakest precisely when protected demand returns.The part I’d still separate is deadline from reclamation. A wall-clock deadline only becomes a hard reclamation bound if expiry can actually stop the borrowed upstream work and release the scarce resource. If cancellation is best-effort and the provider keeps generating, the controller has bounded its local wait, but not necessarily the real capacity recovery time.
So I think the policy needs three explicit pieces: who may borrow, how much may be borrowed, and what restoration contract applies when protected demand returns.
That restoration contract could be measured non-preemptive drain time, an enforceable deadline, or true preemption where the execution layer supports it.
Either way, I agree the restoration term belongs in the policy itself, not buried in implementation behavior.
Agreed that the restoration term has to live in the policy, and that a deadline only counts if expiry can actually release the constrained thing. The part this exposes is that the reservation is hiding more than one constrained resource.
For the controller's own admission slot, abandonment can be real preemption. If the background call is marked expired, the local concurrency slot can be returned to the interactive pool immediately, even while the upstream request continues somewhere else. For that resource, a client-side deadline is an enforceable restoration bound.
For upstream capacity, the same deadline may mean almost nothing. If the provider keeps generating after cancellation, the account-level limit or model-side queue is still being consumed. No capacity has been restored there.
So the restoration contract has to be stated per resource. Its enforceability follows from the release mechanism for that resource. Where cancellation cannot reclaim the upstream resource, deadline-based borrowing is only accounting. The usable guarantee is an unlent floor: some protected capacity must be excluded from borrowing entirely for that resource class.