| name | background-jobs |
| description | Design background job systems for async task processing. Outputs job queue design, worker configuration, retry strategies, failure handling, and monitoring patterns. |
| argument-hint | ["job types","volume","latency requirements","failure tolerance","infrastructure"] |
| allowed-tools | Read, Write |
Background Jobs
Background jobs handle work that shouldn't block the request-response cycle: sending emails, generating reports, processing uploads, syncing with third parties. Good job design addresses idempotency, retries, failure visibility, and concurrency without overcomplicating the system.
Job Queue Options
Redis + Celery (Python) → Mature, feature-rich, good for medium scale
Redis + BullMQ (Node.js) → Modern, TypeScript-first, excellent UI
PostgreSQL + pg_boss → Durable, no extra infra, transactional enqueue
RabbitMQ + workers → Flexible routing, strong delivery guarantees
AWS SQS + Lambda/ECS → Serverless workers, managed scaling
Temporal / Conductor → Durable workflows, long-running orchestration
Celery (Python) Setup
from celery import Celery
from celery.utils.log import get_task_logger
import os
app = Celery(
"myapp",
broker=os.environ["REDIS_URL"],
backend=os.environ["REDIS_URL"],
include=["tasks.orders", "tasks.notifications", "tasks.reports"],
)
app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="UTC",
enable_utc=True,
task_acks_late=True,
task_reject_on_worker_lost=True,
task_routes={
"tasks.reports.*": {"queue": "reports"},
"tasks.notifications.*": {"queue": "fast"},
},
task_annotations={
"tasks.notifications.send_email": {"rate_limit": "100/m"},
},
)
from celery import shared_task
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
@shared_task(
bind=True,
max_retries=3,
default_retry_delay=60,
autoretry_for=(Exception,),
retry_backoff=True,
retry_backoff_max=600,
retry_jitter=True,
acks_late=True,
)
def process_order(self, order_id: str) -> dict:
"""Process an order asynchronously."""
logger.info(f"Processing order {order_id} (attempt {self.request.retries + 1})")
try:
order = order_repo.get(order_id)
if not order:
logger.warning(f"Order {order_id} not found — skipping")
return {"status": "skipped", "reason": "not_found"}
result = order_service.fulfil(order)
logger.info(f"Order {order_id} processed successfully")
return {"status": "success", "order_id": order_id}
except TransientError as exc:
logger.warning(f"Transient error for {order_id}: {exc}")
raise self.retry(exc=exc)
except PermanentError as exc:
logger.error(f"Permanent failure for {order_id}: {exc}")
alert_on_call(f"Permanent job failure: order {order_id}")
return {"status": "failed", "error": str(exc)}
BullMQ (Node.js / TypeScript)
import { Queue, Worker, QueueEvents } from "bullmq";
import Redis from "ioredis";
const connection = new Redis(process.env.REDIS_URL!);
export const orderQueue = new Queue("orders", { connection });
export async function enqueueOrderProcessing(orderId: string) {
await orderQueue.add(
"process-order",
{ orderId },
{
attempts: 3,
backoff: { type: "exponential", delay: 60_000 },
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
}
);
}
const worker = new Worker(
"orders",
async (job) => {
const { orderId } = job.data;
console.();
order = orderRepo.(orderId);
(!order) { : };
orderService.(order);
{ : };
},
{
connection,
: ,
: { : , : },
}
);
worker.(, {
(job && job. >= job..!) {
();
}
});
Idempotency Pattern
@shared_task(bind=True, max_retries=3)
def send_confirmation_email(self, order_id: str, email: str) -> dict:
idempotency_key = f"email:confirmation:{order_id}"
if redis.exists(idempotency_key):
logger.info(f"Email already sent for {order_id} — skipping")
return {"status": "already_sent"}
email_service.send_confirmation(order_id=order_id, to=email)
redis.setex(idempotency_key, 172800, "sent")
return {"status": "sent"}
Job Monitoring
@shared_task
def process_dead_letters():
"""Review and optionally replay failed jobs."""
failed_jobs = celery_app.control.inspect().reserved()
@app.get("/health/workers")
def worker_health():
inspect = celery_app.control.inspect()
stats = inspect.stats()
if not stats:
return {"status": "unhealthy", "workers": 0}
return {
"status": "healthy",
"workers": len(stats),
"queues": {
"orders": redis.llen("celery:orders"),
"fast": redis.llen("celery:fast"),
"reports": redis.llen("celery:reports"),
}
}
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Fix |
|---|
| No idempotency | Retry sends duplicate emails, charges | Idempotency key in Redis/DB before side effects |
| Blocking in workers | One slow job stalls others | Async workers; separate queues by job type |
| No DLQ | Failed jobs silently dropped | Dead letter queue + alerting on DLQ depth |
| Unbounded queue depth | Queue grows without bound | Alert at queue depth thresholds |
| All jobs in one queue | Slow reports block fast notifications | Queue per job type/priority |
| No job timeout | Zombie workers hold tasks forever | Always set soft and hard timeouts |
10 Rules
- Every job that has side effects must be idempotent — retries are guaranteed.
acks_late=True — ack after completion, not on pickup, to prevent job loss on worker crash.
- Separate queues by priority and speed — never let reports block email sends.
- Dead letter queue with alerting — failed jobs must be visible, not silently dropped.
- Exponential backoff with jitter — prevents thundering herd on dependency recovery.
- Job timeouts are mandatory — zombie workers holding tasks cause queue stalls.
- Transient failures retry; permanent failures alert and stop.
- Monitor queue depth per queue — alert when depth grows unexpectedly.
- Track job success rate, latency p99, and failure rate in dashboards.
- Test retry behaviour explicitly — inject failures in tests to verify idempotency.