| name | durable-task-queues |
| description | Durable background job and task queue patterns for AI agent systems. BullMQ Redis-backed queues with concurrency + rate limiting, Inngest event-driven durable functions, Trigger.dev background tasks, dead-letter queue patterns, and job failure diagnostics. Sources: taskforcesh/bullmq, inngest/inngest, triggerdotdev/trigger.dev, OptimalBits/bull, agenda/agenda. |
/durable-task-queues
When to Use
- Agent task takes > 30s — HTTP response timeout risk
- Work must survive server restart (embedding batch, export, analysis)
- Fan-out: one event → many parallel sub-tasks
- "Why did this job fail last night?" — need structured job history
Do NOT use for
- Sub-second operations — queue overhead isn't worth it
- One-off scripts run manually
Decision: BullMQ vs Inngest vs Trigger.dev
Already using Redis in infra?
YES → BullMQ (battle-tested, mature, full queue primitives)
NO →
Want event-driven, serverless-compatible, no extra infra?
YES → Inngest (zero infra, SDK only, Vercel/Next.js native)
Need long-running background tasks (> 15 min) with retries + logging?
YES → Trigger.dev (dashboard, streaming logs, sleep/wait APIs)
BullMQ (Redis-backed queues)
import { Queue, Worker, QueueEvents } from 'bullmq'
const connection = { host: process.env.REDIS_HOST, port: 6379 }
const agentQueue = new Queue('agent-tasks', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
},
})
await agentQueue.add('run-analysis', { repoUrl, sessionId }, {
priority: 1,
delay: 0,
jobId: `analysis:${sessionId}`,
})
const worker = new Worker('agent-tasks', async (job) => {
job.log(`Starting analysis for ${job.data.repoUrl}`)
await job.updateProgress(10)
const result = await runAgentAnalysis(job.data.repoUrl)
await job.updateProgress(100)
return result
}, {
connection,
concurrency: 5,
limiter: {
max: 10,
duration: 1000,
},
})
worker.on('failed', (job, err) => {
logger.error({ jobId: job?.id, err: err.message }, 'job_failed')
})
const deadLetterQueue = new Queue('agent-tasks-dlq', { connection })
worker.on('failed', async (job, err) => {
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await deadLetterQueue.add('dead', {
originalJob: job.name,
data: job.data,
error: err.message,
failedAt: new Date().toISOString(),
})
}
})
Inngest (event-driven durable functions)
import { Inngest } from 'inngest'
const inngest = new Inngest({ id: 'yamtam-agent' })
export const processAgentTask = inngest.createFunction(
{
id: 'process-agent-task',
name: 'Process Agent Task',
retries: 3,
throttle: { limit: 10, period: '1m', key: 'event.data.userId' },
},
{ event: 'agent/task.created' },
async ({ event, step }) => {
const embeddings = await step.run('compute-embeddings', async () => {
return computeEmbeddings(event.data.content)
})
const stored = await step.run('store-to-vector-db', async () => {
return storeEmbeddings(embeddings, event.data.sessionId)
})
step.(, )
result = step.(, () => {
(event..)
})
{ stored, result }
}
)
await inngest.send({
name: 'agent/task.created',
data: { taskId: uuid(), userId, content, sessionId },
})
Trigger.dev (long-running background tasks)
import { task, schedules, wait } from '@trigger.dev/sdk/v3'
export const agentResearchTask = task({
id: 'agent-research',
retry: { maxAttempts: 3, minTimeoutInMs: 1000, multiplier: 2 },
machine: { preset: 'medium-1x' },
run: async (payload: { topic: string; sessionId: string }) => {
console.log(`[research] Starting: ${payload.topic}`)
const sources = await searchWeb(payload.topic)
console.log(`[research] Found ${sources.length} sources`)
await wait.for({ seconds: 5 })
const summaries = await Promise.all(
sources.map( => (src))
)
report = (summaries, payload.)
.()
{ report, : sources. }
},
})
{ tasks }
handle = tasks.(, {
: ,
: req..,
})
result = tasks.(handle., { : })
Job Failure Diagnostics Pattern
worker.on('failed', async (job, err) => {
if (!job) return
const diagnostic = {
jobId: job.id,
jobName: job.name,
attempt: job.attemptsMade,
maxAttempts: job.opts.attempts,
data: job.data,
error: err.message,
stack: err.stack,
startedAt: new Date(job.processedOn!).toISOString(),
failedAt: new Date().toISOString(),
logs: await job.logs,
}
logger.error(diagnostic, 'job_failure_diagnostic')
if (job.attemptsMade >= (job.opts.attempts ?? 1)) {
await alertSlack(`Job ${job.name} exhausted retries: ${err.message}`)
}
})
Anti-Fake-Pass Checklist
❌ BullMQ worker without concurrency limit (Redis connection pool exhausted)
❌ No removeOnComplete/removeOnFail (Redis OOM in hours)
❌ Retry without backoff on rate-limited API (thundering herd on 429)
❌ jobId missing (duplicate jobs on re-enqueue = duplicate side effects)
❌ Inngest step.run() not used (whole function re-runs on any step failure)
❌ DLQ absent (failed jobs silently lost — can't diagnose or replay)
❌ Trigger.dev task without machine preset (memory default too low for LLM ops)
❌ Job failure handler logs only error message (missing input data = can't reproduce)