Background Job Processing Patterns for Async Workloads
Background job processing patterns that survive production: database queues, Redis workers, cron, event-driven consumers, retries, idempotency, and DLQs.
Muhammad Abdul Sami
· Updated · 13 min read
- Background Jobs
- PostgreSQL
- Event-Driven
- Architecture
- APIs
Background job processing is where most backend reliability problems actually live. The request path gets load tests, tracing, and SLOs; the workers that send the emails, charge the cards, and sync the inventory get "fire and forget". This guide lays out the four background job processing patterns we deploy for clients (database-backed queues, broker queues, scheduled jobs, and event-driven workers), when each one is the right call, and the retry, idempotency, and observability discipline that makes any of them safe.
Key Takeaways:
- Choose the queue backing store based on durability and throughput requirements, not hype; start with PostgreSQL if you already run it
- Every job needs a business-meaningful idempotency key and an explicit retry policy that separates transient from permanent errors
- Separate scheduling (when) from execution (what) once cron complexity grows; use leader election so a schedule fires once
- Add a dead-letter queue with a replay tool before launch; poison messages will happen
- Instrument queue depth, p95 age, and failure rate before scaling workers; scale on age, not CPU
- Enqueue jobs in the same transaction as the domain write when both must succeed together
Table of Contents:
- Why Background Jobs Fail in Production
- Pattern 1: Database-Backed Queue
- Pattern 2: Redis / Broker Queue
- Pattern 3: Scheduled and Cron Jobs
- Pattern 4: Event-Driven Workers
- Retries, Idempotency, and Dead Letters
- Observability and Operations
- Production Deployment Checklist
- Case Study: E-Commerce Fulfillment Queue
- Pattern Comparison Matrix
- Frequently Asked Questions
Why Background Jobs Fail in Production
Short answer: Most background job processing systems fail because teams treat async work as "fire and forget" — without idempotency keys, retry budgets, visibility timeouts, or dead-letter handling.
Synchronous APIs hide latency behind HTTP timeouts. Background jobs move that work off the request path: sending emails, generating reports, syncing inventory, running LLM enrichment pipelines, or processing webhooks. The patterns look simple in tutorials. In production, duplicate charges, poison messages, and runaway worker fleets destroy trust within days.
After building async processing layers for backend API engineering clients and data pipeline integrations, we see the same failure modes repeatedly. This guide covers background job processing patterns that survive load spikes, deploys, and partial outages — with code you can adapt today.
Need architecture review? Our backend API engineering team designs job systems alongside your API layer.
Pattern 1: Database-Backed Queue
Database-backed queues store jobs in PostgreSQL (or another RDBMS) and claim rows with FOR UPDATE SKIP LOCKED. This pattern excels when you already run Postgres, need transactional enqueue with business data, and want to avoid operating a separate broker.
When It Works
- Job volume under ~500 jobs/second per queue (with proper indexing)
- Enqueue must be atomic with a database transaction (order + job in one commit)
- Ops team prefers one durable system over Redis + Postgres
- You need SQL-queryable job history for support and audits
Schema and Enqueue
CREATE TYPE job_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'dead');
CREATE TABLE background_jobs (
id BIGSERIAL PRIMARY KEY,
queue TEXT NOT NULL DEFAULT 'default',
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
idempotency_key TEXT,
status job_status NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
locked_at TIMESTAMPTZ,
locked_by TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (queue, idempotency_key)
);
CREATE INDEX idx_jobs_claimable
ON background_jobs (queue, run_at)
WHERE status = 'pending';
Enqueue inside the same transaction as your domain write:
async def create_order_with_fulfillment_job(conn, order: Order) -> int:
async with conn.transaction():
order_id = await conn.fetchval(
"""
INSERT INTO orders (customer_id, total_cents, status)
VALUES ($1, $2, 'confirmed')
RETURNING id
""",
order.customer_id,
order.total_cents,
)
await conn.execute(
"""
INSERT INTO background_jobs (queue, job_type, payload, idempotency_key)
VALUES ('fulfillment', 'ship_order', $1::jsonb, $2)
ON CONFLICT (queue, idempotency_key) DO NOTHING
""",
{"order_id": order_id},
f"ship_order:{order_id}",
)
return order_id
For worker claiming semantics, see our dedicated guide on PostgreSQL FOR UPDATE SKIP LOCKED.
Production Gotcha
Workers crash after claiming a job but before completion. Without visibility timeout (stale lock recovery), jobs sit in processing forever.
Fix: Periodically requeue jobs where locked_at < NOW() - INTERVAL '5 minutes' and status = 'processing'.
Pair database queues with the outbox pattern when you must publish events reliably after commits.
Pattern 2: Redis / Broker Queue
Broker-backed queues (Redis with Bull/Celery, RabbitMQ, Amazon SQS) optimize for throughput and decoupling. Workers pull from a broker; the API never blocks on job execution.
When It Works
- High throughput (thousands of jobs per second)
- Multiple language runtimes consuming the same queue
- Built-in delay queues and priority lanes matter
- You accept at-least-once delivery and design for idempotency
Python Worker (Celery + Redis)
Celery handles retries, countdowns, and result backends out of the box; the part you still own is idempotency.
from celery import Celery
from celery.exceptions import MaxRetriesExceededError
app = Celery("tasks", broker="redis://localhost:6379/0")
@app.task(bind=True, max_retries=5, autoretry_for=(TransientError,))
def process_webhook(self, payload: dict, idempotency_key: str) -> None:
if already_processed(idempotency_key):
return # idempotent skip
try:
handle_webhook(payload)
mark_processed(idempotency_key)
except TransientError as exc:
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
except PermanentError as exc:
send_to_dead_letter(payload, str(exc))
raise
Go Worker (Redis Streams)
Redis Streams with consumer groups give you acknowledgement and pending-entry tracking, which plain lists do not.
package worker
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
type JobHandler func(ctx context.Context, payload json.RawMessage) error
func RunStreamWorker(rdb *redis.Client, stream, group, consumer string, h JobHandler) {
ctx := context.Background()
_ = rdb.XGroupCreateMkStream(ctx, stream, group, "0").Err()
for {
res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: group,
Consumer: consumer,
Streams: []string{stream, ">"},
Count: 10,
Block: 5 * time.Second,
}).Result()
if err != nil {
continue
}
for _, msg := range res[0].Messages {
var payload json.RawMessage
_ = json.Unmarshal([]byte(msg.Values["payload"].(string)), &payload)
if err := h(ctx, payload); err == nil {
_ = rdb.XAck(ctx, stream, group, msg.ID).Err()
}
}
}
}
Broker queues power many data pipeline integrations — nightly ETL, webhook fan-out, and LLM batch enrichment jobs described in our LLM cost reduction guide.
Production Gotcha
Redis without persistence (AOF/RDB) loses in-flight jobs on restart. Fix: Enable persistence or use SQS/RabbitMQ when durability is non-negotiable.
Pattern 3: Scheduled and Cron Jobs
Scheduled jobs run on a timetable — report generation at 06:00 UTC, subscription renewals, cache warming. Cron is the familiar interface; production systems need leader election so only one instance fires each schedule.
When It Works
- Time-based batch work (daily aggregates, invoice runs)
- Delayed execution ("send reminder in 72 hours")
- Periodic health checks and reconciliation sweeps
Postgres + pg_cron Alternative
For teams already on database queues, store run_at on each row and let workers poll — no separate scheduler process. For explicit cron, use a Kubernetes CronJob, AWS EventBridge, or a leader-elected scheduler:
import asyncio
from datetime import datetime, timezone
class LeaderElectedScheduler:
def __init__(self, lock_key: str, redis, enqueue_fn):
self.lock_key = lock_key
self.redis = redis
self.enqueue_fn = enqueue_fn
async def tick(self):
acquired = await self.redis.set(self.lock_key, "1", nx=True, ex=55)
if not acquired:
return
now = datetime.now(timezone.utc)
if now.hour == 6 and now.minute == 0:
await self.enqueue_fn("daily_report", {"date": now.date().isoformat()})
Separate scheduling (when to run) from execution (what workers do). The scheduler enqueues; workers in Pattern 1 or 2 execute. This mirrors how CQRS read models rebuild on a schedule rather than inline.
Production Gotcha
Daylight saving time and timezone bugs cause double runs or missed windows. Fix: Store schedules in UTC; use libraries like zoneinfo (Python 3.9+) for user-local display only.
Pattern 4: Event-Driven Workers
Event-driven workers react to domain events on a log (Kafka, SNS/SQS fan-out, PostgreSQL LISTEN/NOTIFY). Instead of polling, consumers subscribe to streams and process in near real time.
When It Works
- Multiple downstream systems react to the same event
- Event sourcing or CQRS architectures already in place
- You need replay for backfills and audit
Outbox + Relay Worker
Never publish directly inside a transaction handler to an external broker — use the outbox pattern:
-- Same transaction as order insert
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', '12345', 'OrderConfirmed', '{"orderId": 12345}');
A relay worker reads the outbox and publishes to Kafka. Downstream background job workers consume topics and execute side effects.
Cross-service workflows that span multiple aggregates often need the saga pattern for compensating actions when a downstream job fails.
Event-driven patterns complement streaming LLM responses — token streams become events consumed by logging, billing, and moderation workers.
Retries, Idempotency, and Dead Letters
Background job processing is inherently at-least-once. Design every handler to tolerate duplicates.
Retry Policy Table
| Error type | Retry? | Backoff | Destination |
|---|---|---|---|
| Network timeout | Yes | Exponential + jitter | Same queue |
| Rate limit (429) | Yes | Respect Retry-After | Same queue |
| Validation error | No | — | Dead letter |
| Duplicate idempotency key | No | — | Ack / skip |
Full jitter, as described in the AWS Architecture Blog on exponential backoff and jitter, spreads retries so a downstream recovery is not immediately hit by a synchronized thundering herd:
import random
def backoff_seconds(attempt: int, base: float = 2.0, cap: float = 300.0) -> float:
delay = min(cap, base ** attempt)
return delay * (0.5 + random.random()) # full jitter
Dead-Letter Queue (DLQ)
After max_attempts, move jobs to a DLQ with full payload and error context. Alert on DLQ depth; provide an admin replay tool — not manual SQL in production.
Idempotency keys should be business-meaningful: charge:order:9821, not random UUIDs per enqueue attempt.
Our observability practices treat DLQ rate as a first-class SLO alongside API error rate.
Observability and Operations
You cannot operate what you cannot see. Minimum metrics per queue:
- Depth — pending job count
- Age — p95 time from enqueue to start
- Throughput — jobs completed per minute
- Failure rate — failures / attempts
- DLQ rate — poison messages escaping retries
// Prometheus-style counters in Go worker (https://prometheus.io/docs/)
var (
jobsProcessed = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "jobs_processed_total"},
[]string{"queue", "job_type", "status"},
)
jobLatency = prometheus.NewHistogramVec(
prometheus.HistogramOpts{Name: "job_duration_seconds", Buckets: prometheus.DefBuckets},
[]string{"queue", "job_type"},
)
)
Deploy workers with the same rigor as APIs: health checks, graceful shutdown (finish in-flight job or release lock), and rolling updates. On shutdown, workers should extend visibility or release locks so jobs are not stranded.
For AI-heavy workloads, correlate job traces with LLM routing decisions — async enrichment jobs dominate cost if left unbounded.
Structured logging ties each job to a correlation_id from the originating API request — essential when debugging chains that span event-driven architecture consumers and saga orchestrators.
Production Deployment Checklist
Before promoting async infrastructure to production, verify each item:
- Idempotency keys defined for every job type — documented in handler registry
- Retry policy per job type — transient vs permanent error classification
- DLQ with admin replay UI or CLI — not raw database access for support staff
- Graceful shutdown — SIGTERM handler finishes or releases in-flight work within pod termination grace period
- Autoscaling signal — scale on queue age or depth, not CPU alone (workers idle while queue grows)
- Connection pooling — workers share PgBouncer pools; avoid N×M connection explosion (connection pooling guide)
- Secrets rotation — job payloads must not embed long-lived credentials
- Rate limits — outbound API calls from workers respect third-party quotas
Deploy workers on cloud infrastructure with the same CI/CD pipeline as APIs. Version job payload schemas — breaking changes require dual-write migration or feature flags on job type strings.
Worker fleet sizing rule of thumb: target p95 queue age under 60 seconds at peak. If age grows linearly with traffic, add workers until age plateaus. If age stays high with idle CPU, you have lock contention or slow handlers — profile before scaling horizontally.
Case Study: E-Commerce Fulfillment Queue
A mid-market retailer moved fulfillment triggers from synchronous API calls to a Postgres-backed job queue. Previously, POST /orders blocked 800ms–2.4s while inventory, WMS, and email providers responded inline. Checkout abandonment spiked during provider latency events.
Architecture after migration:
- Order API commits order row +
fulfillmentqueue job in one transaction (SKIP LOCKED workers) - Three job types:
reserve_stock,create_wms_shipment,send_confirmation - Outbox row publishes
OrderConfirmedfor analytics (outbox pattern) - Failed WMS calls retry 5× with exponential backoff; permanent SKU errors route to DLQ for ops
Results (30-day window, production traffic):
| Metric | Before | After |
|---|---|---|
| Checkout p95 latency | 1.9s | 120ms |
| Order confirmation email delay | Inline | 4–18s (async) |
| Duplicate shipment incidents | 3/month | 0 (idempotency keys) |
| Support tickets (timeout) | 47/month | 6/month |
The trade-off is explicit: customers see "order confirmed" instantly; shipment tracking appears seconds later. Product copy and UI loading states must reflect async reality — not hide it.
This pattern mirrors API design lessons — return fast, process async, expose status endpoints for long operations.
Pattern Comparison Matrix
| Pattern | Durability | Throughput | Ops complexity | Best for |
|---|---|---|---|---|
| Database queue | High (ACID) | Medium | Low (one system) | Transactional enqueue, moderate volume |
| Redis / broker | Configurable | High | Medium | Fan-out, multi-language workers |
| Scheduled / cron | N/A | Batch | Medium | Time-based batch, reports |
| Event-driven | High (with outbox) | High | High | Multi-consumer, replay, CQRS |
Decision shortcut: Start with a database-backed queue if you run Postgres and volume is modest. Add a broker when polling latency or throughput becomes the bottleneck. Add event-driven consumption when three or more systems need the same domain signal.
Frequently Asked Questions
What is background job processing?
Background job processing moves work off the synchronous request path into asynchronous workers — queues, schedulers, or event consumers — so APIs respond quickly while emails, payments, reports, and integrations run reliably in the background.
Should I use Redis or PostgreSQL for job queues?
Use PostgreSQL when you need transactional enqueue with domain data and moderate throughput. Use Redis or a dedicated broker when you need higher throughput, native delay queues, or polyglot workers. Many teams start with Postgres and migrate hot queues later.
What is the difference between a queue and a topic?
A queue delivers each message to one consumer (competing workers). A topic (pub/sub) delivers a copy to every subscriber. Background job processing usually means queues; event-driven architectures often use topics with multiple independent consumers.
How do I prevent duplicate job execution?
Use idempotency keys stored in the database or cache, design handlers to be safe when run twice, and deduplicate at enqueue with ON CONFLICT DO NOTHING. Never rely on "exactly-once" from the broker alone.
What is a dead-letter queue?
A dead-letter queue (DLQ) holds jobs that failed after all retry attempts. It prevents poison messages from blocking the main queue and gives operators a place to inspect, fix, and replay failures.
How many retries should a background job have?
Most production systems use 3–7 retries with exponential backoff and jitter. Permanent errors (validation, 404) should fail fast to the DLQ without retrying. Tune against your p99 downstream latency and SLA.
Can background jobs replace a message broker entirely?
For many applications, yes — a database queue with SKIP LOCKED plus the outbox pattern covers enqueue, processing, and reliable publishing without Redis or Kafka. See our PostgreSQL queue guide and outbox pattern guide.
How do background jobs relate to CQRS and sagas?
CQRS often rebuilds read models via background jobs triggered by domain events. Sagas orchestrate multi-step distributed workflows as a chain of jobs with compensating actions. See CQRS in practice and saga pattern guide.
Conclusion
Reliable background job processing comes down to a few non-negotiables:
- Match queue pattern to durability and throughput needs
- Idempotency and explicit retry/DLQ policy on every job type
- Observability on depth, age, and failure rate before adding workers
- Transactional enqueue when job and domain write must succeed together
The pattern you choose matters less than operational discipline. Database queues, Redis workers, cron schedulers, and event consumers all work — when handlers are idempotent, failures are visible, and poison messages have somewhere to go.
At HinterBuild:
- Backend API Engineering
- Data Pipelines & Integrations
- Cloud Infrastructure & DevOps
- Observability & Monitoring
Schedule a consultation for async architecture review.
Free consultation
Book a free consultation call on background jobs & async processing
30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.
Book a meeting
Resources:
Keep reading
Related articles
CQRS in Practice: Commands, Queries, and Projections
CQRS in practice without the buzzwords: command handlers, denormalized read models, async projections, rebuild strategies, and when not to use it.
Read post
API Design Mistakes That Kill Performance: Fixes That Work
Eight API design mistakes that kill performance — N+1 calls, over-fetching, offset pagination, sync side effects — with Go, Python, and TypeScript fixes.
Read post
Webhook Design for Reliability at Scale: Production Patterns
Webhook Design for Reliability at Scale guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable.
Read post
Multi-Tenancy Patterns: Database Strategies for SaaS
Learn multi-tenancy patterns through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
