Designing Reliable Background Jobs

distributed-systemsreliability

Background jobs fail. Networks blip, dependencies rate-limit you, and processes get killed mid-flight. This post collects the patterns I reach for to make that failure boring instead of catastrophic.

🔗Retries with backoff

Naïve retries stampede. Add exponential backoff with full jitter so retries spread out instead of synchronising into a thundering herd.

Here’s the whole helper, included straight from the source file that lives next to this post — no copy-paste drift:

retry.py (lines 1–15)
import time
import random


def with_retries(fn, *, attempts=5, base=0.2, cap=10.0):
    """Call fn, retrying with exponential backoff and full jitter."""
    for attempt in range(1, attempts + 1):
        try:
            return fn()
        except TransientError:
            if attempt == attempts:
                raise
            backoff = min(cap, base * 2 ** (attempt - 1))
            time.sleep(random.uniform(0, backoff))  # full jitter

A fenced block, for comparison, gets full token colouring:

result = with_retries(lambda: charge_card(order), attempts=5)

🔗Idempotency

Every job must be safe to run twice, because at-least-once delivery means it will run twice eventually.

TechniqueGood forCost
Idempotency keyExternal side effectsA dedupe table
Natural upsertDatabase writesCareful unique keys
Conditional writesCounters, state transitionsCompare-and-set support
  • Assign each job a stable idempotency key
  • Make writes upserts where possible
  • Add a dead-letter queue for poison messages

🔗The shape of it

Producer enqueues a job onto a queue; a worker consumes it with retries.

Treat the queue as the source of truth, not the worker’s memory. If a worker dies, another must be able to pick up exactly where it left off.1

That’s the core. Retry with jitter, stay idempotent, and make every step observable enough to debug at 3am.

  1. This is why you commit progress to durable storage before acking the message, never after.