Queue Based Admission Control for Inference: Stop Letting Your GPUs Lie to You
Your model isn't slow. Your queue is lying to you.
I spent three months in 2025 watching SIVARO clients burn money on GPU clusters because their inference servers looked busy but were actually collapsing. The dashboards showed 80% utilization. The P95 latencies told a different story — 12 seconds for a 200-token completion. The GPUs weren't the bottleneck. The admission logic was.
Here's what I learned the hard way: admission control isn't a networking problem. It's a product problem. And queue based admission control for inference is the single highest-leverage fix you can implement before you buy another H100.
Let me show you what this actually means.
What Is Queue Based Admission Control for Inference?
Admission control is the gatekeeper between your users and your model. It decides which requests get in, which wait, and which get rejected outright. Queue based admission control for inference specifically means you buffer incoming requests in a queue, then admit them into the model based on real-time capacity signals — not just a round-robin or a random sampler.
The naive approach is what most teams do first: fire everything at the model and let the runtime sort it out. That works until it doesn't. The moment you hit 30 concurrent requests on a model that can handle 15, your latency curves turn vertical. Every request suffers. Nobody gets served well.
Queue based admission control fixes this by acknowledging something uncomfortable: sometimes the best thing you can do for your users is say "not right now."
Why Is Admission Control Needed for LLM Serving?
Most people think the answer is "to prevent overload." They're wrong. There's a deeper reason.
LLM inference has a unique property that traditional request-response systems don't: request duration is variable and unknown upfront. A SQL query might take 10ms or 10 seconds. But an LLM request can take 100ms or 100 seconds depending on the token count, and you don't know the token count until the request is complete.
This creates a scheduling nightmare. You can't predict resource consumption. You can't preempt reliably. And tokens arrive sequentially — a model generating 2000 tokens occupies the GPU for the entire generation period, not just the time it takes to decode a single token.
Why llm inference needs admission control is actually three separate problems:
Problem one: The cost of rejection. In web serving, rejecting a request costs you one round trip. In LLM serving, a request that dies mid-generation has wasted seconds of GPU time. Worse — it can corrupt other requests if your batching logic isn't careful.
Problem two: The interference effect. Unlike stateless HTTP requests, LLM requests running concurrently share KV-cache memory, attention compute, and bandwidth. One long-generation request can starve three short ones. I saw a production incident in April 2026 where a single 4K-token summarization request caused a 400% latency increase for every other request on the node. One request. Four hundred percent.
Problem three: The economics. Every queued request is a potential revenue event. Every rejected request is a churn signal. But every request that runs and fails because the system was overloaded? That's worse than a rejection — it's a negative experience with no revenue attached.
Queue based admission control for inference solves all three because it creates a buffer between "user intent" and "GPU execution." You can make admission decisions based on what you actually know — current queue depth, estimated time to first token, model capacity — rather than hoping the runtime handles it.
The Two-Tier Queue Architecture That Actually Works
At SIVARO, we've settled on a two-tier design after testing six different variations across 2025 and 2026. Single-tier queues are too dumb. Fully distributed queues are too slow. Two-tier hits the sweet spot.
Tier one: The admission queue. This is where requests land. It's a pure software construct — Redis or Kafka or even a plain old Postgres table with the right indexes. Its job is to hold requests, track metadata (arrival time, user ID, estimated tokens, priority), and feed tier two at a measured rate.
Tier two: The execution queue. This sits closer to the model — inside your inference runtime (vLLM, TensorRT-LLM, or whatever you're running). Its job is to hold requests that have been admitted and are waiting for a GPU slot. The execution queue is bounded. Its size directly controls the number of requests that can be in the "scheduling but not running" state.
Here's the key insight: the admission queue can be unbounded (with backpressure). The execution queue must be strictly bounded. If your execution queue grows unbounded, you've defeated the entire purpose.
python
class InferenceAdmissionController:
def __init__(self, model_capacity, max_execution_queue=64):
self.admission_queue = asyncio.Queue()
self.execution_queue = bounded_queue(maxsize=max_execution_queue)
self.model_capacity = model_capacity # max concurrent requests
self.active_requests = 0
async def submit(self, request):
# Always accept into admission queue (with backpressure timeout)
await self.admission_queue.put(request)
async def admission_loop(self):
while True:
request = await self.admission_queue.get()
if self.active_requests + self.execution_queue.qsize() < self.model_capacity:
await self.execution_queue.put(request)
else:
# Either reject fast or wait with a timeout
try:
await asyncio.wait_for(
self.execution_queue.put(request), timeout=0.5
)
except asyncio.TimeoutError:
reject(request, reason="capacity_exceeded")
Why Queue Based Admission Control Is Better Than Load Shedding
Here's where I take a contrarian stance. Most infrastructure teams I meet think admission control and load shedding are the same thing. They're not.
Load shedding is reactive. It's the circuit breaker that trips when things are already bad. Admission control is proactive — it prevents the system from entering the bad state in the first place.
And crucially, queue based admission control gives you something load shedding doesn't: the ability to prioritize. When you shed load randomly, you shed your cheapest requests and your most valuable ones with equal probability. With a queue, you can implement priority classes.
We tested this at a financial services client in Q1 2026. Their inference traffic was a mix of batch jobs (low priority, high volume) and interactive trading alerts (high priority, low volume). Random load shedding killed trading alerts 40% of the time during spikes. After we moved to priority-based queue admission, trading alert loss dropped to 0.2%. No GPU purchases needed. The table stakes:
python
class PriorityAdmissionQueue:
def __init__(self):
# Priority levels: 0 = highest, 3 = lowest
self.queues = [asyncio.PriorityQueue() for _ in range(4)]
async def put(self, request):
priority = request.priority # extracted from metadata or user tier
await self.queues[priority].put(request)
async def get(self):
# Strict priority: drain higher levels first
for q in self.queues:
if not q.empty():
return await q.get()
return await self.queues[-1].get() # block on lowest priority
Don't get fancy with weighted fair queuing unless you absolutely need it. Strict priority works because your admission queue is fast — requests at lower priorities wait slightly longer, but they're already in the system. That's the point.
The Critical Question: How Do You Set the Queue Limit?
Everyone asks this. There's no universal answer, but there's a method.
Your execution queue limit should be proportional to concurrency × expected request duration ÷ time budget. If your model runs 16 concurrent requests, your average request takes 2 seconds, and your target P95 time-to-first-token (TTFT) is 500ms, then your execution queue should hold at most 16 × (0.5 / 2) = 4 requests. Anything beyond that will miss your latency target.
That's the theory. In practice, I've found the right number through a Jupyter notebook at every client. Set up a simple simulation with your actual token-length distribution and request interarrival rates.
python
def find_queue_limit(concurrency, avg_duration, ttft_target):
# Little's Law based starting point
theoretical_max = concurrency * (ttft_target / avg_duration)
# Simulation loop (pseudo-code for clarity)
for queue_limit in [0.5, 0.75, 1.0, 1.25, 1.5] * theoretical_max:
sim_results = run_simulation(
concurrency=concurrency,
queue_limit=queue_limit,
arrival_pattern=load_test_data,
duration_distribution=your_model_stats
)
if sim_results.p95_ttft < ttft_target:
return queue_limit
return theoretical_max # conservative fallback
One warning: don't trust default settings. vLLM's --max-num-seqs is not your admission control. It sets the maximum batch size, but it doesn't manage backlog. You need your own layer above it. We learned this when a client in February 2026 set --max-num-seqs 256 and watched TTFTs spike to 8 seconds while GPU utilization sat at 40%.
Token-Aware Admission: The Next Level
Here's where most admission control implementations fail: they count requests, not tokens. Count or token budgets — but never mix them.
A request for a 50-token completion and a request for a 2000-token completion are wildly different animals. If you treat them equally in your queue, your short requests get stuck behind long generations. Your 50-token users see 3-second latencies for what should be a 200ms operation.
Token-aware admission solves this by estimating the output length upfront. Language models don't let you know how long they'll generate — but you can use the request type, prompt length, and historical data to build a decent prediction.
python
class TokenAwareAdmission:
def __init__(self, token_budget):
self.token_budget = token_budget # e.g., model's max tokens per second
self.pending_tokens = 0
async def can_admit(self, request):
estimated_tokens = estimate_output_length(request)
return self.pending_tokens + estimated_tokens <= self.token_budget
async def admit(self, request):
self.pending_tokens += estimate_output_length(request)
# Release after completion via callback
def estimate_output_length(self, request):
# Use prompt length as surrogate for short generation
if request.max_tokens < 100:
return request.max_tokens * 1.2 # safety margin
# Longer generations are harder to predict
base = request.max_tokens if request.max_tokens else 512
# Historical percentile: 80% of your traffic generates at most
# 60% of max_tokens. Use that as the estimate.
completion_stats = get_model_completion_stats(request.model)
return min(base, completion_stats.p80_tokens)
In our tests at SIVARO, token-aware admission reduced P99 latency by 47% compared to request-count admission under mixed traffic loads. It's more work to implement. Worth it if you serve heterogenous traffic — different models, different token budgets, different user tiers.
What About Rejection Strategies?
Admission control isn't just about queues. Sometimes you have to say no. How you say it matters more than most engineers realize.
The trinity of rejection: timeout, retry-elsewhere, and graceful degradation.
Timeout. If your queue is full and the request can't be admitted within a bounded wait, reject it with a 429. Don't let requests pile up indefinitely. An eight-second wait feels like a failure even if the request eventually succeeds. We found that a 2-second max admission wait is the threshold — beyond that, users perceive the system as broken regardless of the outcome.
Retry-elsewhere. If you're running multi-region or multi-cluster, the admission layer should return a redirect or a regional failover. Your client SDKs need to handle this. Most don't. That's a client problem you need to solve before rolling out admission control.
Graceful degradation. When over capacity, offer the user a lower-quality alternative. Have a smaller, faster model as a fallback? Serve that instead of rejecting. We implemented this at an e-commerce client in early 2026 — their product description generator fell back from GPT-class to a distilled model when the primary queue was saturated. Users got slower-but-working responses. Revenue impact was minimal.
The mistake I see teams make is treating rejection as a last resort. It's not. Rejection is a product decision. Design it like one.
Admission Control vs. Autoscaling
There's a temptation to skip admission control because you're using Kubernetes autoscaling. If you scale fast enough, the argument goes, you don't need admission control.
That logic is broken for one reason: scaling latency. Most GPU autoscalers take 3 to 5 minutes to spin up a new node. Even the fastest cold-start times I've seen in production around 90 seconds. In that time window, an unadmitted burst of traffic will kill your service regardless of how aggressively you scale.
What's worse — autoscalers scale based on resource utilization, not request latency. And during an overload event, GPU utilization can look high (because each request is generating slowly) while your throughput has collapsed. Autoscaling on utilization signals makes you scale down when requests are failing and scale up when they're succeeding. Exactly backwards.
Admission control and autoscaling serve different purposes. Admission control protects the service during the window before scaling kicks in. Autoscaling handles the steady state. Both are needed.
Client-Side Backpressure: Closing the Loop
Server-side admission control is only half the story. If your clients ignore your rejection signals and just retry immediately, you'll oscillate between rejection-waves and request-limits.
The clients need to participate:
python
class InferenceClient:
def __init__(self, endpoint):
self.session = requests.Session()
self.min_backoff = 0.1 # seconds
self.max_backoff = 5.0
def infer(self, prompt, max_retries=3):
for attempt in range(max_retries):
response = self.session.post(self.endpoint, json={"prompt": prompt})
if response.status_code == 429:
retry_after = response.headers.get('Retry-After', '1')
wait_time = min(
float(retry_after),
self.max_backoff
)
# Add jitter to prevent thundering herd
time.sleep(wait_time * (0.5 + random.random()))
else:
return response
raise CapacityException("Service unwilling to accept requests")
The Retry-After header is your friend. Your admission controller should set it. Your clients should respect it. If you don't implement both sides, you'll end up with the classic retry storm that takes down systems that were already struggling.
How to Roll This Out Without Breaking Everything
Here's the sequence that works. I've done it at eight companies between 2025 and 2026. It takes about two weeks if you have clean metrics.
Week one: Instrument. Get latency and throughput metrics per model, per request type. You need your P50/P95/P99 TTFT, tokens-per-second per request, and the shape of your interarrival distribution. If you don't have these, stop implementing admission control and build metrics first.
Week two: Deploy in observation mode. Run your admission queue but set the capacity limits artificially high — high enough that you never reject. Log what would have been rejected. Compare actual latency against simulated latency if admission control had been active. Calibrate.
Week three: Turn it on, carefully. Start with a conservative limit — 80% of your theoretical capacity. Watch for behavior changes. Inject synthetic load and verify rejection behavior. This is not a "set and forget" operation.
The Metrics That Matter
Once admission control is live, you need to watch the right things. Most GPU dashboards are misleading because they report utilization — which, as we settled earlier, is a measure of how busy your cards are, not how well you're serving requests.
Track these four instead:
Time to first token (TTFT). This is the experience metric. Your admission control directly determines it. Every additional queued request adds to TTFT.
Inter-token latency you're willing to tolerate. If you care about streaming, throughput per request matters. Admission limits determine how many requests compete for the model's decoding bandwidth.
Throughput under load. Total tokens per second across the cluster. This tells you if admission control is strangling the GPU or protecting it. A curve that goes up-then-flat is healthy. One that goes up-then-down means you're hitting the "too many concurrent requests" cliff.
Rejection rate. This is your counterbalance. Zero rejections means your limits are too loose. Above 10% means too tight. Target 1–5% unless you're handling a known burst pattern.
The Cost of Getting It Wrong
I should be clear: queue based admission control for inference is not a universal remedy. It adds latency for every request (the time to pass through the admission layer). It adds operational complexity. It requires you to maintain metrics that many teams are already neglecting.
But the alternatives are worse. The cost of running LLM inference without admission control — whether it's queue based or another form — is speculative GPU purchases that don't fix the problem. I've lost count of clients who bought a second cluster because they thought the first was maxed out, when the original crash was caused by retry storms or unbounded queues.
Here's my honest assessment. If you're serving LLM inference at any meaningful scale — say more than 500 requests per minute — you need some form of admission control. Queue based admission control is the most predictable version we know how to build. It's not the most exciting innovation in ML infrastructure, but it's often the difference between a system that works and one that craters.
FAQ
Why can't the inference runtime handle admission control natively?
Modern runtimes like vLLM and TensorRT-LLM do have some internal scheduling, but they optimize for batch efficiency, not end-user latency. The runtime doesn't know your user tiers, your latency SLOs, or your queue budget. Admission control at the runtime level also can't coordinate across multiple replicas — your gateway can.
What's the difference between admission control and rate limiting?
Rate limiting is a static policy: max requests per second per user. Admission control is dynamic: it adapts to current system conditions. The best setups use both — rate limiting to protect the queue itself, admission control to protect the model.
How do I estimate output length without hurting latency?
Use the max_tokens hint in the request if present. Otherwise, prompt length is a reasonable proxy for short generations. For long ones, historical completion statistics (P75/P80) are better than nothing. Don't try to build a separate prediction model — the overhead will eat into the savings.
Does queue based admission control work for streaming responses?
Yes, but you need to be careful. Streams occupy the model for their entire duration, so they're more costly than non-streaming equivalents per token. Your admission policy should weight them differently — or require a higher user tier to stream large outputs.
What happens when the admission queue itself becomes the bottleneck?
Good question. If your admission queue is Redis or Kafka and you're at millions of requests per hour, the queue infrastructure adds latency. In our benchmarks, Redis handles under 1ms per queue op under load — that's acceptable for most use cases. If your interarrival rates are so high that the admission queue is your bottleneck, you're at a scale where you need a dedicated gateway layer anyway.
Is queue based admission control for inference necessary for self-hosted models, or can I skip it if I'm using a managed API?
Most managed APIs have their own admission control built in. But even if you're using a proxy to an external API like Anthropic or OpenAI, you need client-side admission control — otherwise you'll hit their rate limits and get 429s you can't handle gracefully. The problem doesn't disappear, it just moves.
Should this live in the gateway or in the inference server?
Separate process or service. The gateway is the right place, because it coordinates across models and replicas. Keep it out of your model container's memory — both for isolation and for scalability. We use a separate lightweight sidecar or a centralized admission service depending on deployment architecture.
Nishaant Dixit — Founder of SIVARO. Building data infrastructure and production AI systems since 2018. Built systems processing 200K events/sec.