The most expensive agent incident may start with a healthy dashboard. Requests still return, workers are still busy, and the queue is simply getting longer. Then a slow model or tool turns that waiting line into timeouts, retries, duplicate work, and a chat screen that looks frozen.
The missing control is usually not another worker. It is AI agent backpressure: an explicit contract that stops your system from accepting more work than it can finish within a useful deadline. This guide shows how to design that contract for interactive and background agent workflows, including tenant fairness, queue limits, retry rules, and honest user-facing states.
Why a queue is not backpressure
A queue is storage between producers and workers. Backpressure changes what happens when that storage fills or when downstream capacity falls.
Without it, an API accepts every request, each agent fans out into model and tool calls, and workers keep retrying when a provider slows down. The system appears available right up until most requests have waited too long to matter. A long queue is not resilience; it is delayed rejection.
This is more acute for agents than for ordinary request handlers:
- One customer request can create many model, retrieval, and tool calls.
- A slow dependency holds concurrency for much longer than a typical API call.
- Retries can multiply load exactly when capacity is lowest.
- One bulk import can consume the same model quota that serves an interactive user.
Recent developer discussion reflects this split: rate limits are a downstream provider signal, while backpressure is the application’s decision to queue, defer, degrade, or reject work before it becomes a pile-up. AWS’s agent orchestration guidance likewise calls for controls at workflow, agent, tool, and model layers. That cross-layer design is the useful gap: a token budget or a durable queue alone does not tell the API what to do when capacity is gone.
Define the overload contract before writing a worker
Start by deciding what each work class is allowed to do under pressure. Do not let an implementation detail make this decision accidentally.
| Work class | User expectation | When capacity is low | Useful response |
|---|---|---|---|
| Interactive chat | Fast first response | Reject or degrade quickly |
429 or 503 with retry guidance |
| Short action | Finishes soon | Admit only if deadline is realistic | Accepted or fast retryable failure |
| Background task | Completion matters more than speed | Queue within a hard bound | Job ID and truthful wait state |
| Bulk work | Can wait and be throttled | Reserve only leftover capacity | Scheduled or deferred |
| Critical repair | Must not starve | Keep reserved slots | Priority admission |
The key question is: can this request still produce value if it waits? If the answer is no, a quick response is kinder than a spinner. If the answer is yes, return a job reference and show queued, running, paused, or failed state—never a fake percentage.
Give every run one deadline that begins at admission, not at the first model call. A five-minute workflow that waits four minutes in a queue only has one minute left to execute. Passing that deadline to every stage prevents a stale job from consuming fresh capacity.
type Admission =
| { kind: "admitted"; runId: string; deadlineAt: number }
| { kind: "queued"; runId: string; positionHint?: number }
| { kind: "deferred"; retryAfterSeconds: number }
| { kind: "rejected"; retryAfterSeconds: number; reason: string };
function deadlineFromNow(ms: number) {
return Date.now() + ms;
}
Bound every place work can accumulate
An agent system often has more than one queue: an HTTP connection pool, an in-process semaphore, a job broker, a model provider, a tool client, and a database connection pool. Capping only the broker just moves the overload elsewhere.
Use limits at four scopes:
- Global concurrency protects shared providers and databases.
- Per-tenant concurrency prevents a noisy customer from consuming all slots.
- Per-workflow concurrency prevents a fan-out plan from creating fifty simultaneous calls.
- Per-dependency concurrency isolates a slow browser, search API, or model route from healthy work.
Queue length also needs a cap. Keep large input files and raw web pages outside the queue; put object references in the job record. A bounded queue should contain enough information to restart work, not unlimited payloads that grow memory while waiting.
Here is a deliberately small admission function. In production, the counters should be atomic and shared across replicas, such as through a database, Redis script, or queue-native primitive.
type Capacity = { running: number; queued: number };
async function admitRun(input: {
tenantId: string;
priority: "interactive" | "background" | "bulk";
capacity: Capacity;
}): Promise<Admission> {
const MAX_RUNNING = 24;
const MAX_QUEUED = 200;
const tenantRunning = await countTenantRunning(input.tenantId);
if (tenantRunning >= 3) {
return { kind: "deferred", retryAfterSeconds: 30 };
}
if (input.capacity.running < MAX_RUNNING) {
return createAndLeaseRun(input, deadlineFromNow(90_000));
}
if (input.priority !== "interactive" && input.capacity.queued < MAX_QUEUED) {
return enqueueRun(input, deadlineFromNow(15 * 60_000));
}
return { kind: "rejected", retryAfterSeconds: 15, reason: "capacity_unavailable" };
}
Numbers here are examples, not universal settings. Derive initial concurrency from your provider quota, average end-to-end latency, tool limits, and a safety margin. Then test with slow dependencies; capacity that is safe during normal latency may be destructive during a partial outage.
Separate admission control from rate limiting
These controls work together but answer different questions.
- Rate limiting: may this customer start another request in this time window?
- Budgeting: may this run spend another token, tool call, or dollar?
- Admission control: can the system start or promise this work now?
- Backpressure: what changes upstream when the answer is no?
That distinction matters. A tenant could remain under its request-per-minute allowance while every shared worker slot is occupied by slow runs. Conversely, a system can have idle workers but still need to stop a tenant that has exhausted its cost budget. Keep the reason in the response and logs; “rate limited” is misleading if the actual cause is a full model queue.
Make fairness a scheduling rule, not a hope
FIFO is simple but vulnerable. A single large tenant that adds thousands of background jobs first can make a newly arrived interactive task wait behind all of them.
Use a small number of priority bands and round-robin tenant selection within each band. Reserve capacity rather than letting high-priority work consume every slot forever. For example:
- 60% of slots for interactive work
- 25% for normal background work
- 10% for critical repair or approval-resume work
- 5% for bulk work, reclaimed only when other bands are empty
Add ageing so a background job that has waited a long time eventually receives a boost. Do not expose precise queue positions unless your scheduler can guarantee them; changing priority and retries make an exact number untrustworthy.
A useful job key is (tenant_id, work_class, created_at). It gives your scheduler enough information to make a fair choice without reading prompt contents. This also makes fairness observable: measure queue age by tenant and work class, not only global queue depth.
Choose a load-shedding policy for each feature
When capacity is exhausted, “retry later” is not the only option. Pick the least harmful behavior that still preserves user intent.
| Policy | Best for | Example |
|---|---|---|
| Fail fast | Interactive operations | Return a retryable error before opening a stream |
| Defer | Non-urgent work | Schedule a report for later execution |
| Degrade | Optional enrichment | Skip web research and return grounded local results |
| Coalesce | Repeated equivalent work | Join identical document-summary requests |
| Drop | Explicitly disposable events | Discard duplicate typing or preview events |
Never silently downgrade a request that changes its safety or truthfulness. If retrieval is unavailable, say that the answer used only the available context. If an agent cannot complete a write action, do not claim it did.
Coalescing deserves special care. Two users may ask the same question but have different permissions, source filters, or tenant data. Coalesce only when the authorization context and input fingerprint are equivalent.
Retries must yield to pressure
Retries are a common overload amplifier. A timeout does not prove the provider did not receive the request, and an immediate retry steals capacity from new work. Treat retry eligibility as policy, not a catch block.
For each attempt, record the dependency, idempotency key, deadline, error class, and retry count. Retry only errors that are both transient and safe. Honor Retry-After when a provider supplies it, use exponential backoff with jitter, and stop when the run deadline has passed.
async function callModelWithRetry(run: Run, request: ModelRequest) {
for (let attempt = 0; attempt < 3; attempt++) {
if (Date.now() >= run.deadlineAt) throw new Error("deadline_exceeded");
try {
return await modelGate.withPermit(() => model.generate(request));
} catch (error) {
if (!isRetryable(error) || attempt === 2) throw error;
const delay = retryDelayWithJitter(error, attempt);
if (Date.now() + delay >= run.deadlineAt) throw error;
await recordRetry(run.id, attempt, error);
await sleep(delay);
}
}
throw new Error("unreachable");
}
The semaphore wraps the whole call, including retry decision boundaries, so unlimited retries cannot bypass your concurrency limit. For side-effecting tools, pair this with idempotency keys and reconciliation; a timeout after a successful write is not safe to blindly repeat.
Stream honestly when work is delayed
Streaming can hide overload. If a chat connection stays open while the real job waits in a worker queue, the user sees an empty response and assumes the model is thinking.
Send a state transition instead: accepted, queued, running, waiting_for_dependency, paused_for_approval, completed, or failed. Keep the text factual: “Waiting for capacity; retrying automatically until 10:42” is useful. “Almost done” is not.
For requests that cannot wait, reject before streaming begins with a stable, documented error payload:
{
"error": "capacity_unavailable",
"retry_after_seconds": 15,
"request_id": "run_8b2",
"safe_to_retry": true
}
Clients should respect the delay and should not create a new run for every retry. Reuse an idempotency key so a flaky network does not turn one user click into many queued tasks.
Monitor the signals that reveal a bad promise
CPU and model latency are not enough. Track these per model route, tool, tenant tier, and work class:
- admitted, queued, deferred, rejected, and completed runs
- queue depth and oldest queue age
- time from admission to first useful output
- execution time versus queue wait time
- retries, retry reason, and retries after deadline
- cancellations and jobs removed before execution
- concurrency utilization and dependency error rate
- completion rate and cost per completed workflow
Alert on combinations, not a single number. Rising queue depth plus rising oldest age means demand exceeds service. Flat queue depth plus rising execution latency points to a downstream dependency. Full queue plus idle workers suggests a scheduler, lease, or partitioning bug.
Run a simple game day: make one model route slow, send mixed tenant traffic, then restore it. Verify that interactive users get fast answers or fast rejection, bulk work stops expanding, retries do not spike, and the queue drains without a second surge. The recent LLM serving backpressure analysis makes the same practical point: unbounded waiting turns a smaller overload into many late failures.
A rollout plan that will not surprise users
Start in observe-only mode. Calculate whether each request would have been admitted, queued, or rejected, but do not enforce it yet. Compare that result with real queue age and completion data.
Then introduce a small global cap and one per-tenant cap for a non-critical background feature. Add explicit states to the UI and API before making rejections common. Finally, add priority reservations and dependency-specific limits after you understand normal and degraded capacity.
Document the contract for clients: which errors are retryable, how long to wait, when a job will be queued, and how to poll or subscribe for state. Backpressure succeeds when callers change behavior; it fails when every caller retries at once.
FAQ
What is AI agent backpressure?
AI agent backpressure is the set of limits and responses that prevent an agent system from accepting work faster than models, tools, and workers can complete it. It includes bounded queues, admission decisions, concurrency limits, load shedding, and retry guidance.
Is an AI agent queue enough to prevent overload?
No. A queue only delays work unless it has a hard capacity and the producer changes behavior when it fills. Pair it with admission control, deadlines, and a clear defer-or-reject response.
How is backpressure different from an AI agent rate limiter?
A rate limiter controls how frequently a caller may start work. Backpressure responds to current system capacity across queues, workers, models, and tools. Use both: a caller can be within its rate limit while the shared system has no safe capacity.
Should interactive AI requests be queued?
Only if the wait remains useful and visible. For chat-like interactions, fast retryable rejection or an explicitly queued asynchronous job is usually better than keeping an empty stream open for minutes.
How do I keep one tenant from blocking everyone else?
Set per-tenant running and queued limits, schedule tenants fairly within each priority band, and reserve shared capacity for interactive or critical work. Measure queue age by tenant so unfairness is visible.
What should an agent do after a provider returns 429 or overload?
Honor the provider’s retry guidance when present, retry only within the run’s deadline, use backoff with jitter, and do not let the retry bypass dependency concurrency limits. If the deadline will be missed, return an honest deferred or failed state.
Top comments (0)