Flow Control for Autonomous Agents: Backpressure and Queues That Keep Production Alive
Run several Antigravity agents at once and the problem stops being how smart they are and becomes how little your downstream can absorb. Here is a flow-control design — bounded queue, semaphore, token bucket, backpressure, dead-letter — with TypeScript and real numbers.
Running a 50-million-download app business and four technical blogs by myself, the chores I'd love to hand to an agent keep multiplying: drafting articles, keeping localized release notes in sync, comparing AdMob mediation settings, batch-fixing the same crash class Crashlytics keeps surfacing. The work I've ground out by hand since 2014 is exactly the work I most want to automate.
So when I first tried running Antigravity agents in parallel — on the naive assumption that "more agents means faster" — what I got was not speed but congestion. I stood up an article-generation agent for each of four sites and ran six app-operations agents at the same time. Pushes to GitHub, deploys to Cloudflare, AdMob console actions, and Stripe lookups all converged at once, and downstream services started returning 429 one after another. The agents themselves were working correctly. What broke was that the rate at which agents produced work outran the rate at which downstream could absorb it.
The lesson hit hard: in agent operations, what bites first is not intelligence but throughput control. Line up ten brilliant agents, and if downstream can process one item per second, the other nine agents' worth of work has to pile up somewhere, get dropped, or error out. Here is a design for controlling throughput structurally so production survives, with the implementation alongside.
Why Flow Matters Before Intelligence
While you run a single agent, flow rarely shows up as a problem. One agent works serially, so the work downstream receives is naturally serial too. The trouble appears the instant you raise parallelism because you want it faster.
Parallelizing is deceptively easy — you just add agents. But downstream — external APIs, databases, the billing system, console actions — does not scale its capacity to suit you. The AdMob console can't take dozens of actions per second, the GitHub API has rate limits, and Stripe has a per-second request ceiling. Double the number of agents against the same downstream, and you've only doubled the speed at which un-processable work accumulates.
The first wall I hit was exactly this asymmetry. The day I scaled my article agents to four, generation itself got faster, yet the push stage threw 429s in bursts, retries stacked up, and the whole thing ended up slower than when I ran a single agent. Raise concurrency without controlling flow and your attempt to go faster makes you slower — counterintuitive, but exactly what queueing theory predicts.
Measure Flow With Three Numbers
"It clogs and it's a pain" is a feeling, not a design. Before touching flow control, fix three measuring sticks. I think along these three axes.
Arrival rate: items of work produced per unit time by the agents. For article generation, "how many push requests appear per minute."
Service rate: items downstream can clear per unit time. For the GitHub API, "how many requests per second still return 200."
Concurrency: how many jobs are in flight against downstream right now — "how many pushes are in flight at this moment."
The relationship is simple. When arrival rate continuously exceeds service rate, the backlog grows without bound. If it only exceeds it briefly, a queue can absorb the spike. So flow control reduces to two things: shaping arrival rate to stay at or below service rate, and absorbing temporary overshoot safely.
Getting the numbers isn't hard. The scheduler knows the arrival rate. Service rate is either documented in the downstream's rate-limit page or measurable by finding the threshold where 429s begin. Concurrency is counted by the queue you're about to build.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Chain autonomous agents through queue, then a semaphore for concurrency, then a token bucket for rate shaping, then backpressure, then a dead-letter store — and structurally prevent downstream 429s and cost spikes, with TypeScript you can drop in as-is
✦Learn from running four blogs at four posts a day alongside six apps in background, what exactly breaks the moment arrival rate exceeds service rate, shown with concrete numbers
✦Set concurrency limits, token-bucket refill rate, queue length, and the overflow destination using real numbers that contain damage without strangling your own throughput
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
First, look at the "breaking code" I actually wrote. It just fans agent-generated tasks out at once with Promise.all.
// Before: shove every arriving job downstream at onceasync function runAgentTasks(tasks: AgentTask[]): Promise<void> { // whether tasks.length is 4 or 400, all of them start at once await Promise.all( tasks.map(async (task) => { const result = await agent.run(task); // generation is fast await pushToGitHub(result); // downstream clogs here }), );}
This runs fine while there are a few tasks. But when four sites' worth of tasks arrive almost simultaneously, each carrying several articles, pushToGitHub fires dozens of times at once. GitHub starts returning 429 partway through, and failed tasks are swallowed here (Promise.all rejects if any one rejects, but the pushes that already succeeded can't be undone). The result was the worst state of all: "half of it landed, and I can't tell which ones failed."
There are three problems. First, there is no cap on concurrency. Second, there is no mechanism to match arrival rate to service rate. Third, overflowed work has nowhere to go. We'll fill these in one at a time.
Decouple Arrival From Processing With a Queue
The first move is to insert a queue between arrival and processing. Agents only enqueue work; only a worker pulling from the queue sends it downstream. Now the wave of arrivals no longer slams directly into the processing side.
// A bounded queue. When full, it makes the enqueue side wait.class BoundedQueue<T> { private items: T[] = []; private waiters: Array<() => void> = []; constructor(private readonly maxLength: number) {} // When full, wait until a slot frees up (this is where backpressure begins) async enqueue(item: T): Promise<void> { while (this.items.length >= this.maxLength) { await new Promise<void>((resolve) => this.waiters.push(resolve)); } this.items.push(item); } dequeue(): T | undefined { const item = this.items.shift(); const waiter = this.waiters.shift(); if (waiter) waiter(); // a slot opened, so wake one waiting enqueue return item; } get length(): number { return this.items.length; }}
The key is maxLength. Without a cap, when arrival rate keeps exceeding service rate, the queue grows until it eats all your memory. With a cap, the moment it fills, enqueue is made to wait — and that very act of waiting becomes the backpressure I'll explain below.
Cap Concurrency With a Semaphore
The worker that pulls work off the queue and sends it downstream must not run in unlimited number. Set a concurrency cap matched to downstream's service rate. A semaphore handles this.
// A semaphore that limits the number of jobs passing through to Nclass Semaphore { private available: number; private queue: Array<() => void> = []; constructor(permits: number) { this.available = permits; } async acquire(): Promise<void> { if (this.available > 0) { this.available -= 1; return; } await new Promise<void>((resolve) => this.queue.push(resolve)); } release(): void { const next = this.queue.shift(); if (next) { next(); // let one waiter through (permit is handed off) } else { this.available += 1; } }}
The permit count is the concurrency cap against downstream. To allow at most three concurrent pushes to GitHub, set permits to three. Don't pick this number by feel — pick it from measurement, as below. Too high and 429s return; too low and you can't use downstream's capacity.
Match Downstream Rate Limits With a Token Bucket
Capping concurrency still isn't enough. Many downstreams rate-limit not by "how many at once" but by "how many per unit time." Against an API capped at ten calls per second, even with a concurrency of one, hammering it in quick succession returns 429. This is where a token bucket helps.
// Consume tokens refilled at a fixed rate; tolerate some burstclass TokenBucket { private tokens: number; private lastRefill = Date.now(); constructor( private readonly capacity: number, // max bucket size (burst allowance) private readonly refillPerSec: number, // tokens refilled per second ) { this.tokens = capacity; } private refill(): void { const now = Date.now(); const elapsedSec = (now - this.lastRefill) / 1000; this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillPerSec); this.lastRefill = now; } // Wait until a token is available, then consume one async take(): Promise<void> { this.refill(); while (this.tokens < 1) { const waitMs = ((1 - this.tokens) / this.refillPerSec) * 1000; await new Promise((r) => setTimeout(r, Math.ceil(waitMs))); this.refill(); } this.tokens -= 1; }}
The trick is setting refillPerSec slightly below downstream's rate limit. For an API allowing ten calls per second, putting refillPerSec at eight or nine leaves headroom for measurement error and retries, and you almost never hit 429. capacity is the burst allowance. If your pattern is "usually idle but occasionally arrives in clumps," set the capacity a bit larger.
Backpressure: Stop Accepting When It Clogs
Combine these parts and the worker's flow becomes "pull from queue, take a semaphore permit, take a token, send downstream." When downstream lags, tokens don't accumulate, permits don't free up, and the worker can't pull from the queue. The queue then fills, and enqueue is made to wait.
This "enqueue is made to wait" is backpressure — the mechanism that propagates downstream congestion upstream and throttles intake. The fact that downstream is clogged travels through the queue to the agents, and the agents can't inject new work, so they wait. As a result, arrival rate automatically converges toward service rate.
With an interface that only exposes submit, the agent side never has to be aware backpressure exists. When it clogs, they naturally wait; when it frees, they naturally proceed. That "no need to be aware" state is what makes operations sustainable.
Overflowed Work Goes to a Dead Letter
Even with backpressure throttling intake, if downstream is down for a long time, waiting alone gets you nowhere. Work that fails after a fixed number of retries should not be swallowed — send it to a dead-letter store, a place to park jobs that failed processing. The point is to not discard it but to leave it where a human can pick it up later.
async function handleWithDeadLetter(item: AgentTask): Promise<void> { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { await pushToGitHub(item.result); return; } catch (err) { if (attempt === maxAttempts) { // park it with a reason in a store (KV, R2, logs) await deadLetterStore.put(item.id, { item, reason: String(err), failedAt: new Date().toISOString(), }); return; } // exponential backoff; give downstream time to recover await new Promise((r) => setTimeout(r, 2 ** attempt * 500)); } }}
When Promise.all burned me, the worst part was "I can't tell which ones failed." Once I added a dead letter, I settled into a routine: check the parked items each morning, and if downstream has recovered, re-inject them. You can't avoid failures themselves, but you can avoid failures becoming invisible.
Set Thresholds From Real Numbers
Finally, how to choose these parameters. Pick by feel and you'll either over-defend and go slow, or under-defend and hit 429. I measure and decide in this order.
First, measure service rate. Against downstream, raise concurrency from one in small steps and find the value just before error rate starts climbing. In my setup, pushes to GitHub were stable around three concurrent and eight per second. Cloudflare deploys are better serial anyway, so I cap them at one concurrent.
Second, set queue length. The queue is a buffer for absorbing temporary overshoot. Too long, and when it clogs, stale work lingers forever and loses freshness. I use "service rate × tolerable delay" as a guide. Clearing eight per second and tolerating up to two minutes of delay gives a cap of about 960. For article generation at a few dozen per day, far smaller is plenty.
Third, draw the line between backoff and dead letter. My default is "retry three times, then park." More retries than that delays recovery when downstream is genuinely down, and stacks up wasted cost.
The numbers differ per site and per app, but the method is the same. Measure first, set thresholds just before errors appear, absorb overshoot in the queue, and divert what the queue can't absorb to a dead letter. Keep that order, and you can raise parallelism without breaking production.
Flow control is an unglamorous mechanism, but if you're running multiple agents in production, it's worth adding before you add intelligence. If you want to try it next, pick one spot where you currently fan work out with Promise.all, and start by inserting a BoundedQueue and a semaphore. Even that one spot should noticeably cut how often 429s appear.
I hope this helps anyone running multiple agents in production the way I am. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.