HinterBuild logoHinterBuild
Backend Systems · 13 min read

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, author

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

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

sql
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:

python
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.

python
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.

go
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:

python
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:

sql
-- 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 typeRetry?BackoffDestination
Network timeoutYesExponential + jitterSame queue
Rate limit (429)YesRespect Retry-AfterSame queue
Validation errorNoDead letter
Duplicate idempotency keyNoAck / 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:

python
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
go
// 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:

  1. Idempotency keys defined for every job type — documented in handler registry
  2. Retry policy per job type — transient vs permanent error classification
  3. DLQ with admin replay UI or CLI — not raw database access for support staff
  4. Graceful shutdown — SIGTERM handler finishes or releases in-flight work within pod termination grace period
  5. Autoscaling signal — scale on queue age or depth, not CPU alone (workers idle while queue grows)
  6. Connection pooling — workers share PgBouncer pools; avoid N×M connection explosion (connection pooling guide)
  7. Secrets rotation — job payloads must not embed long-lived credentials
  8. 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 + fulfillment queue job in one transaction (SKIP LOCKED workers)
  • Three job types: reserve_stock, create_wms_shipment, send_confirmation
  • Outbox row publishes OrderConfirmed for 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):

MetricBeforeAfter
Checkout p95 latency1.9s120ms
Order confirmation email delayInline4–18s (async)
Duplicate shipment incidents3/month0 (idempotency keys)
Support tickets (timeout)47/month6/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

PatternDurabilityThroughputOps complexityBest for
Database queueHigh (ACID)MediumLow (one system)Transactional enqueue, moderate volume
Redis / brokerConfigurableHighMediumFan-out, multi-language workers
Scheduled / cronN/ABatchMediumTime-based batch, reports
Event-drivenHigh (with outbox)HighHighMulti-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:

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

Keep reading