A production AI agent is not a function call — it is a stateful, long-running job that spans model calls, tool invocations, and retries across seconds to minutes. The moment you run more than one at a time, you need three things: a durable queue (Redis + BullMQ) so a crash does not drop in-flight work, a durable state store so execution progress survives restarts, and idempotent workers so a redelivered job does not double-spend money or send two emails. Synchronous "await the agent" code is fine for a demo and a liability in production. Build async from day one if you expect concurrency.
- Redis + BullMQ is the de-facto Node queue for agents: job persistence, retries with backoff, delayed and repeatable jobs, and visibility into stuck work.
- Durable state is separate from the queue. Keep execution progress and conversation memory in Redis for hot access and Postgres/S3 for the durable copy.
- Exactly-once execution does not exist in distributed systems. Design for at-least-once delivery plus idempotent handlers (jobId dedup).
- Size workers with Little's Law:
workers = ceil(in_flight / concurrency_per_worker). Use the calculator below. - A worker that holds a tool's side effect (send email, charge card) must be idempotent or a redelivery costs you a customer.
- Why synchronous agents break at scale
- The async agent anatomy
- Queue: Redis + BullMQ
- Durable state: what must survive a crash
- Idempotency: don't double-execute
- Failure modes in async agent systems
- Sizing the queue (Little's Law)
- Reference architecture (Docker Compose)
- When to go async vs stay synchronous
- FAQ
- Related Cluster Intelligence
Why synchronous agents break at scale
The first agent most teams write is a single await runAgent(input). It works in a test with one request. It breaks the moment reality shows up:
- A model call times out at 30s and the whole request dies, taking the half-finished workflow with it.
- The process restarts (deploy, OOM, crash) and every in-flight agent vanishes — no resume, no record.
- Ten users hit it at once and they queue on one thread, each blocking the others.
- A retry fires twice because the network dropped the ack, and your agent charges the customer's card twice.
None of these are edge cases; they are Tuesday. The fix is not a better model — it is an architecture that treats each agent run as a job that can be queued, retried, and resumed.
Our multi-agent outbound pipeline writeup runs on exactly this pattern, and vendor fallback only helps if the job that called the model is still alive to try the next vendor.
The async agent anatomy
An async agent run flows through five stages, and each stage fails differently if you skip it:
| Stage | Responsibility | Breaks if missing |
|---|---|---|
| Trigger | Accept the request, enqueue a job | Requests lost on restart |
| Queue | Hold jobs, order retries | In-flight work dropped on crash |
| Worker | Execute the agent loop | No concurrency, one thread |
| State store | Persist progress + memory | No resume, context lost |
| Side-effect guard | Idempotent external writes | Double charges, duplicate emails |
The queue and the state store are different systems with different jobs. The queue decides what runs when; the state store remembers where a run was. Confusing them — stuffing everything in Redis and hoping — is the most common design error.
Queue: Redis + BullMQ
Redis is the right queue backbone for agents because it is fast, persists to disk (AOF/RDB), and BullMQ sits on top with the features you actually need:
- Job persistence: a job survives a worker crash because it lives in Redis, not in process memory.
- Retries with backoff:
attempts+backoffre-run failed jobs instead of dropping them. - Delayed and repeatable jobs: schedule follow-ups (e.g., "check the ticket in 10 minutes") natively.
- Visibility: BullMQ Board or the API shows stuck, completed, and failed jobs — you can see a backlog before users do.
A minimal worker looks like this. The handler must be idempotent (see below) because BullMQ delivers at-least-once.
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
const agentQueue = new Queue('agent-runs', { connection });
new Worker('agent-runs', async (job) => {
// job.id is stable across retries — use it as the idempotency key
return runAgent(job.data, { runId: job.id });
}, {
connection,
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
concurrency: 5,
});
concurrency: 5 means one worker process handles 5 jobs in flight. That number drives your sizing math later.
Durable state: what must survive a crash
Not everything belongs in the queue. Separate hot state (needed mid-run) from durable state (needed after restart or for audit):
- Hot state — Redis: current step, intermediate tool outputs, short-term conversation memory. Fast, ephemeral-ish, lives next to the worker.
- Durable state — Postgres: completed execution records, final outputs, audit trail. Survives a Redis wipe.
- Blob state — S3/object storage: large attachments, generated files. Too big for Redis.
The rule: if losing it means a user's run cannot resume or be explained later, it goes to the durable store. Redis is for speed, not for truth. Many teams learn this the hard way when a Redis restart erases "finished" runs that were only in memory.
For self-hosted orchestrators, n8n's queue mode uses the same Redis + Postgres split — the compose guide shows the wiring, and the Postgres vs SQLite benchmark explains why Postgres wins under concurrency.
Idempotency: don't double-execute
Distributed systems give you at-least-once delivery, never exactly-once. A job can be delivered twice (ack lost, worker died mid-commit). Your handler must make a second run harmless:
Idempotency key
Derive a stable key from the job (e.g. job.id or a content hash) and record "already done" in Redis/Postgres before the side effect. A repeat sees the marker and returns the cached result.
Side effects last
Do the deterministic work first, the external write (email, charge, post) last, and only after the idempotency check. Ordering is what stops the double-send.
Outbox over direct call
Write "send email" to a durable outbox and let a separate process drain it. A crash between "decided to send" and "sent" no longer loses the intent or sends twice.
Idempotent downstream
If you call Stripe or an ESP, use their idempotency headers. Your retry and theirs must agree, or you move the problem downstream.
"Exactly-once" is a lie sold by marketing copy. At-least-once plus idempotent is the honest, working design.
Failure modes in async agent systems
Stuck jobs
A worker dies holding a job with no timeout; BullMQ never requeues it. Set lockDuration and a stall checker, or jobs hang forever.
Poison messages
One malformed input retries 3× and goes to the failed queue — fine. But if your retry has no cap, it hammers the model and burns money. Cap attempts and route failures to a dead-letter.
Queue backlog
Throughput drops, jobs pile up, latency climbs. Little's Law tells you how many workers you actually need — under-provisioning is the silent SLA killer.
Orphaned side effects
A job runs, writes to a DB, then crashes before acking. On redelivery it writes again. Without idempotency the second write is a duplicate row or a double charge.
Sizing the queue (Little's Law)
Little's Law says the number of in-flight jobs L = λ × W where λ is arrival rate (jobs/sec) and W is average handling time (sec). If each worker holds C jobs in flight, you need workers = ceil(L / C). The calculator does it live:
Reference architecture (Docker Compose)
A minimal async-agent stack: Redis for the queue and hot state, Postgres for durable execution records, and one or more worker containers. Shared env uses an x- extension so it is never a bare top-level key.
x-agent-env: &agent-env
REDIS_URL: redis://redis:6379
DATABASE_URL: postgres://agent:${AGENT_DB_PASSWORD}@postgres:5432/agent
IDENTITY_KEY_TTL: 86400
MAX_RETRIES: 3
services:
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
postgres:
image: postgres:16
environment:
POSTGRES_USER: agent
POSTGRES_PASSWORD: ${AGENT_DB_PASSWORD}
POSTGRES_DB: agent
volumes:
- pg-data:/var/lib/postgresql/data
agent-worker:
image: your-registry/agent-worker:latest
<<: *agent-env
deploy:
replicas: 4
depends_on:
- redis
- postgres
volumes:
redis-data:
pg-data:
Four worker replicas at concurrency: 5 each handle 20 in-flight jobs — tune replicas from the calculator, not from a guess. Postgres holds durable runs; Redis holds the queue and hot memory. Pin image tags and keep secrets in env files, not the compose.
When to go async vs stay synchronous
- Stay synchronous for a low-volume internal tool, a single-user CLI, or anything under a few concurrent runs where a crash just means "retry the one request." The simplicity is worth more than the resilience.
- Go async the moment you have concurrent users, long-running agents (seconds+), retries you cannot afford to lose, or any paid side effect (email, charge, post). That is most production agents.
The cost of async is operational: you now run Redis, watch a queue, and think about idempotency. The cost of not going async is lost work and angry customers, and it shows up exactly when traffic peaks.
FAQ
Deploy this stack in production
Every config, default, and failure mode in this guide comes from live deployment, not documentation. Our Make.com playbook covers the orchestration patterns end to end.
Get the Make.com Automation Playbook →Download this guide’s assets
Get the configuration and data files referenced in this guide. Subscribe and we’ll send the bundle to your inbox.
Get the bundle →Is Redis durable enough to trust as the queue?
Redis persists via RDB snapshots and AOF append logs, so a restart does not erase the queue. But "durable enough" depends on your tolerance: for the system of record (finished runs, audit), use Postgres/S3. Redis is the queue and hot cache; Postgres is the truth. Losing Redis should lose in-flight jobs, not history.
Why BullMQ instead of a raw Redis list?
A raw LPUSH/RPOP list has no retries, no delayed jobs, no visibility, and no stall detection. BullMQ adds all of that on top of Redis. You can build it by hand, but you will re-implement backoff, dead-lettering, and observability — BullMQ already did, and the bugs are someone else's solved problem.
Can I really never get exactly-once?
Not across the queue, worker, and external system boundary. You can get effectively-once by making every step idempotent and using an outbox, so a redelivery is harmless. That is the real goal; "exactly-once" as a global guarantee does not exist in practical distributed systems.
How many workers do I actually need?
Use the calculator above: workers = ceil(arrival_rate × handling_time / concurrency_per_worker). Start there, then watch the queue depth in production and add replicas when backlog grows. Under-provisioning shows up as latency, not errors, so instrument the queue.
Engineering transparency: The architecture described (Redis + BullMQ queue, separate durable state store, at-least-once delivery with idempotent handlers, Little's Law sizing) reflects standard distributed-systems practice for job queues and is not a benchmark we ran. BullMQ options shown (attempts, backoff, concurrency, lockDuration) are documented BullMQ behaviors; confirm the exact field names against the BullMQ version you deploy. The worker calculator applies Little's Law (L = λ × W) with simple arithmetic — the Redis RAM figure is a rough estimate from an assumed per-job size and should be validated against your real job payloads, not treated as a capacity guarantee. n8n queue-mode references point at its Redis + Postgres split as a concrete self-hosted example.
Related Cluster Intelligence
- Hetzner vs DigitalOcean vs AWS for n8n Docker →
- Smartlead vs Instantly: Cold Email Cost Per Reply →
- MCP Tool Poisoning Prevention: Architecture Over Patches →
- Clay vs Apollo: Cost Per Enriched Record →
- Multi-Agent Outbound Pipeline 2026: Enterprise Production Architecture & 3-Tier Agent Mesh →
- Eradicating Data Poisoning in Multi-Agent Outbound Systems →