Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
queuesbackendarchitecturereliabilitydistributed-systems

Background jobs in practice: queues, retries, idempotency, and dead-letter failures

Transactional enqueue, idempotency, retry strategy and dead-letter handling - plus database queues, Redis, RabbitMQ, Kafka, SQS and workflow engines compared.

T

team

18 min read
A diagram showing a job queue, a worker, and a dead-letter queue. The worker is processing a job from the queue, and the dead-letter queue is where failed jobs go.

Two failures define background job systems, and every team meets both. The first is the job that ran twice - a customer charged twice, an email sent twice, an inventory count decremented twice. The second is the job that never ran at all, because the transaction that should have enqueued it rolled back, or a worker died holding it, or it failed five times and quietly disappeared.

Both are consequences of the same thing: a queue is a distributed system, and the guarantees it offers are weaker than the ones your code assumes. This covers what queues actually promise, the transactional enqueue problem, idempotency, retry strategy, dead-letter handling, and an honest comparison of database queues, Redis, RabbitMQ, Kafka, SQS and workflow engines.

What a queue actually promises

Start here, because most job bugs trace back to assuming a stronger guarantee than you have.

GuaranteeWhat it meansReality
At-most-onceDelivered zero or one timesFire and forget. Messages are lost on failure
At-least-onceDelivered one or more timesWhat you actually have. Duplicates are normal, not exceptional
Exactly-once deliveryDelivered precisely onceNot achievable across a network. Anyone claiming it means something narrower
Exactly-once processingThe effect happens once, even if delivery repeatsAchievable - by making the consumer idempotent. This is the goal

The distinction in that last row is the whole game. You cannot stop a message arriving twice; you can make the second arrival do nothing. Even SQS FIFO, which AWS markets as exactly-once processing, does not join your business transaction to the delete call - a crash between the effect and the acknowledgement still redelivers.

The transactional enqueue problem

This is the bug that produces jobs referencing rows that do not exist, and it is almost universal in systems using an external queue.

ts
// Broken, and it looks completely reasonable
await db.transaction(async (tx) => {
  const order = await tx.order.create({ data: input })
  await queue.publish('order.created', { orderId: order.id })   // ← outside the tx
  await tx.inventory.decrement({ sku: input.sku })              // ← this throws
})
// Transaction rolls back. The message does not.
// A worker now picks up a job for an order that was never created.
The reverse ordering is equally broken: commit first, then publish, and a crash between the two loses the job silently. There is no ordering that fixes this, because two systems cannot commit atomically.

The outbox pattern

Write the message to a table inside the same transaction as your business data. A separate process reads that table and publishes to the real queue. The transaction gives you atomicity; the relay gives you at-least-once delivery to the broker.

sql
CREATE TABLE outbox (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    topic        text        NOT NULL,
    payload      jsonb       NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now(),
    published_at timestamptz
);

CREATE INDEX outbox_unpublished_idx ON outbox (id)
    WHERE published_at IS NULL;
The partial index keeps the relay's query cheap regardless of how much history the table accumulates.
ts
// Now atomic: either both happen or neither does
await db.transaction(async (tx) => {
  const order = await tx.order.create({ data: input })
  await tx.inventory.decrement({ sku: input.sku })
  await tx.outbox.create({
    data: { topic: 'order.created', payload: { orderId: order.id } },
  })
})

// A relay process, running continuously
async function relay() {
  const rows = await db.$queryRaw`
    SELECT id, topic, payload FROM outbox
    WHERE published_at IS NULL
    ORDER BY id
    FOR UPDATE SKIP LOCKED
    LIMIT 100`

  for (const row of rows) {
    await queue.publish(row.topic, row.payload, { messageId: String(row.id) })
    await db.outbox.update({
      where: { id: row.id },
      data: { published_at: new Date() },
    })
  }
}
The relay can crash between publishing and marking the row published, so a message may be sent twice. That is fine - passing the outbox id as the message id lets the consumer deduplicate.

If your queue *is* your database, this problem disappears entirely: the enqueue is part of the transaction by construction. That single property is the strongest argument for database-backed queues, and it is why teams running Postgres often should not reach for a broker at all.

Idempotency

Since duplicates are guaranteed, every job handler needs to be safe to run more than once. There are three ways to get there, in increasing order of effort and reliability.

1. Make the operation naturally idempotent

sql
-- Not idempotent: running twice charges twice
UPDATE accounts SET balance = balance - 100 WHERE id = $1;

-- Idempotent: the second run is a no-op
UPDATE orders SET status = 'shipped', shipped_at = now()
WHERE id = $1 AND status = 'paid';

-- Idempotent by construction
INSERT INTO notifications (user_id, kind, sent_at)
VALUES ($1, $2, now())
ON CONFLICT (user_id, kind) DO NOTHING;
Absolute assignment beats relative arithmetic. Guarding on current state beats assuming it. A unique constraint beats checking first, because the check-then-act has a race and the constraint does not.

2. Deduplicate on a job key

ts
async function handle(job: Job) {
  const key = `job:done:${job.idempotencyKey}`

  // Claim the key. NX means only the first caller wins.
  const claimed = await redis.set(key, 'processing', 'NX', 'EX', 86400)
  if (!claimed) {
    metrics.increment('jobs.duplicate_skipped')
    return
  }

  try {
    await doTheWork(job)
    await redis.set(key, 'done', 'EX', 86400)
  } catch (err) {
    await redis.del(key)   // release so a retry can proceed
    throw err
  }
}
This is a mutual-exclusion lock, not a durability guarantee. If the process dies after the work but before the second set, the key expires and the job runs again. Good enough for emails; not good enough for payments.

3. Record the effect in the same transaction as the effect

The only genuinely reliable pattern. Write a row proving the work was done, in the same transaction that does the work. A duplicate hits the unique constraint and stops.

sql
CREATE TABLE job_executions (
    idempotency_key text PRIMARY KEY,
    job_type        text        NOT NULL,
    result          jsonb,
    completed_at    timestamptz NOT NULL DEFAULT now()
);
ts
async function chargeCustomer(job: ChargeJob) {
  return db.transaction(async (tx) => {
    // If this insert fails, the whole transaction aborts and nothing happens twice
    try {
      await tx.jobExecution.create({
        data: { idempotencyKey: job.idempotencyKey, jobType: 'charge' },
      })
    } catch (err) {
      if (isUniqueViolation(err)) return { skipped: true }
      throw err
    }

    await tx.payment.create({ data: { orderId: job.orderId, cents: job.cents } })
    await tx.order.update({
      where: { id: job.orderId },
      data: { status: 'paid' },
    })
  })
}
Both writes and the execution record commit together. There is no window in which the effect happened but the record did not.

Where the key comes from

  • Derive it from the business event, not from the job's own id. charge:order_874:attempt_1 deduplicates across retries and across a relay republishing; a random UUID per enqueue deduplicates nothing.
  • Include everything that makes the operation distinct. A key of send_email:user_42 blocks the second genuine email to that user; send_email:user_42:order_874:shipped does not.
  • Give it a lifetime longer than your maximum retry window. A key that expires while the job is still being retried is not doing anything.
  • Pass it to downstream services so idempotency composes rather than stopping at your boundary.

Retries

Retrying is the default response to failure and the default cause of outages. The difference is entirely in whether you classify errors before retrying them.

Classify first

ClassExamplesAction
TransientNetwork timeout, 503, connection reset, deadlockRetry with backoff. This is what retries are for
Rate limited429, quota exceededRetry, but respect Retry-After rather than your own schedule
Permanent400, validation failure, malformed payloadDo not retry. Fail straight to the dead-letter queue
Not-yetA referenced row does not exist yetRetry briefly, then treat as permanent - it may never arrive
UnknownAn unmapped exceptionRetry a limited number of times, then dead-letter and alert
Retrying a permanent failure twenty times wastes capacity and delays the alert that would have told you about it. Classification is what makes the dead-letter queue meaningful.
ts
class PermanentError extends Error {}       // never retry
class TransientError extends Error {}       // retry with backoff
class RateLimitError extends Error {
  constructor(public retryAfterMs: number) { super('rate limited') }
}

async function processJob(job: Job) {
  try {
    await handle(job)
  } catch (err) {
    if (err instanceof PermanentError) {
      await deadLetter(job, err)             // straight to the DLQ, no retries
      return
    }
    if (err instanceof RateLimitError) {
      await requeue(job, err.retryAfterMs)   // their schedule, not ours
      return
    }
    throw err                                // normal backoff path
  }
}

Backoff, with jitter

ts
function nextDelayMs(attempt: number): number {
  const base = Math.min(1000 * 2 ** attempt, 3_600_000)   // cap at one hour
  return Math.random() * base                             // full jitter
}

// attempt 0 →  0–1s
// attempt 1 →  0–2s
// attempt 2 →  0–4s
// attempt 6 →  0–64s
// attempt 12 → 0–1h
Full jitter, not a fixed schedule. When a downstream service recovers from an outage, ten thousand jobs whose backoff expires in the same second will knock it straight back over.
  • Cap total attempts and total elapsed time, not just attempts. Six retries over eight hours is a different failure mode from six retries over a minute.
  • Add a circuit breaker per dependency. If the payment provider is down, stop trying and fail fast until a probe succeeds. Retrying into a dead service converts one outage into two.
  • Never retry non-idempotent work without an idempotency key. This is how duplicate charges happen.
  • Retry at one level only. An HTTP client retrying three times inside a job that retries five times is fifteen calls, and nobody planned for that.
  • Make the visibility timeout or lock duration longer than your slowest run. Otherwise the queue redelivers a job that is still in progress, and now two workers are doing it.

Dead-letter queues

A dead-letter queue is where jobs go after exhausting their retries. Every serious queue has the mechanism. The part teams get wrong is treating it as a destination rather than a workflow.

hcl
resource "aws_sqs_queue" "orders" {
  name                       = "orders"
  visibility_timeout_seconds = 300
  message_retention_seconds  = 1209600   # 14 days, the maximum

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.orders_dlq.arn
    maxReceiveCount     = 5
  })
}

resource "aws_sqs_queue" "orders_dlq" {
  name                      = "orders-dlq"
  message_retention_seconds = 1209600
}
The DLQ's queue type must match the source - a FIFO queue needs a FIFO dead-letter queue. Set the retention to the maximum; a DLQ that expires messages has thrown away the evidence.

The rules that make it useful

  • Alert on depth greater than zero. A DLQ nobody watches is a delete queue with extra steps. This is the single most common failure in this whole article.
  • Preserve the failure context, not just the payload - the error, the stack, the attempt count, the timestamp of each attempt, and a correlation ID. Without it, triage means reproducing the failure from scratch.
  • Build a redrive path before you need one. After deploying a fix you want to replay the queue, and writing that tooling during an incident is the worst time to write it.
  • Redrive in small batches, and re-check idempotency. Replaying ten thousand jobs at once is how you take down the service you just fixed.
  • Set a retention alarm. SQS caps retention at fourteen days; a message that ages out is gone.
  • Track dead-letter rate by job type. One job type dominating the DLQ is a bug report, not a queue problem.
sql
-- A DLQ table for a database-backed queue, with the context that matters
CREATE TABLE dead_letters (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    job_type        text        NOT NULL,
    payload         jsonb       NOT NULL,
    idempotency_key text,
    attempts        integer     NOT NULL,
    last_error      text        NOT NULL,
    error_class     text,
    first_failed_at timestamptz NOT NULL,
    dead_lettered_at timestamptz NOT NULL DEFAULT now(),
    redriven_at     timestamptz
);

CREATE INDEX dead_letters_pending_idx ON dead_letters (job_type, dead_lettered_at)
    WHERE redriven_at IS NULL;
error_class is what lets you group failures. Two hundred dead letters that are all the same bug is a very different situation from two hundred distinct ones.

Choosing a queue

Six options, and the right one depends far more on what you already run and what guarantees you need than on throughput numbers you will never approach.

Database queues

sql
CREATE TABLE jobs (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    job_type        text        NOT NULL,
    payload         jsonb       NOT NULL,
    idempotency_key text UNIQUE,
    run_after       timestamptz NOT NULL DEFAULT now(),
    attempts        integer     NOT NULL DEFAULT 0,
    max_attempts    integer     NOT NULL DEFAULT 5,
    locked_until    timestamptz,
    locked_by       text
);

CREATE INDEX jobs_ready_idx ON jobs (run_after)
    WHERE locked_until IS NULL;

-- Claim atomically. SKIP LOCKED is what lets many workers run without collision.
UPDATE jobs SET
    locked_until = now() + interval '5 minutes',
    locked_by    = $1,
    attempts     = attempts + 1
WHERE id = (
    SELECT id FROM jobs
    WHERE run_after <= now()
      AND (locked_until IS NULL OR locked_until < now())
      AND attempts < max_attempts
    ORDER BY run_after
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
RETURNING id, job_type, payload, idempotency_key;
Incrementing attempts at claim time rather than on failure is what makes poison messages terminate. A worker that crashes mid-job still burned an attempt.

Strengths: transactional enqueue for free, one system to operate and back up, queryable with SQL, trivially debuggable. Comfortably handles thousands of jobs per second on decent hardware, which is more than most applications ever generate.

Weaknesses: competes with your application for database resources; dead tuples from high job churn create vacuum pressure; no built-in fan-out; polling adds latency unless you pair it with LISTEN/NOTIFY. Mature libraries exist - River and pgmq for Postgres, good_job and Solid Queue in Ruby - so you rarely write this yourself.

Redis queues

Redis-backed queues - BullMQ, Sidekiq, Celery with a Redis broker - are the common middle ground. Fast, low latency, good tooling, mature dashboards.

  • Strengths: sub-millisecond enqueue, rich scheduling and rate limiting in the libraries, excellent dashboards, BRPOP and BLMOVE give push semantics with no polling.
  • Weaknesses: durability is only as good as your Redis persistence settings. The default RDB snapshot means a crash loses everything since the last snapshot - which for a job queue means work nobody has any record of.
  • The trap: an eviction policy of allkeys-lru on the instance holding your queue will silently delete jobs under memory pressure. Queue data belongs on an instance with noeviction and AOF enabled, separate from your cache.
  • No transactional enqueue. You need the outbox pattern, or you accept the gap.

RabbitMQ

A proper message broker with routing. Exchanges, bindings and routing keys let you express topologies - fan-out, topic routing, per-tenant queues - that the simpler options cannot.

  • Strengths: flexible routing, per-message acknowledgement, priority queues, built-in dead-letter exchanges, delayed messages via plugin, mature management UI, publisher confirms.
  • Weaknesses: another stateful service to run and cluster. Quorum queues added real durability but at a throughput cost. Operationally, it is the option most likely to need someone who understands it.
  • Best for: work distribution with genuinely complex routing, where multiple consumers need different subsets of the same messages.

Kafka

Kafka is not a job queue, and using it as one is the most common Kafka mistake. It is a durable, ordered, partitioned log - consumers track an offset rather than acknowledging individual messages.

What you want from a job queueWhat Kafka gives you
Acknowledge one message, retry it aloneAn offset. One stuck message blocks its partition
Delay a retry by five minutesNothing built in - you write retry topics
Per-job priorityNot a concept
Dead-letter one messageYou publish it to another topic yourself
Thousands of independent consumersParallelism is capped by partition count
Kafka is excellent at what it is for: event streaming, replayable history, high-volume pipelines, and multiple independent consumers reading the same stream. That is a different problem from running background jobs.

Use Kafka when you need an event log that many systems consume independently and can replay from a point in time. Use something else when you need per-message retry and acknowledgement.

SQS

Managed, effectively unlimited scale, nothing to operate. The correct default on AWS unless you need routing or ordering it cannot provide.

StandardFIFO
ThroughputNearly unlimited300 API actions/sec, 3,000 messages/sec with batching of 10
OrderingBest effortStrict, within a MessageGroupId
DeliveryAt-least-once, duplicates possibleMarketed as exactly-once processing; still redelivers on a crash before delete
ParallelismUnconstrainedOne in-flight message per group at a time
In-flight limit120,00020,000
  • Visibility timeout is a lease, capped at 12 hours from first receive. Extending does not reset that ceiling - for longer work, use Step Functions or split the job.
  • ChangeMessageVisibility applies immediately but is not remembered. On the next receive it reverts to the queue's configured value.
  • Retention maxes out at 14 days. After that the message is gone, including from the DLQ.
  • Message size is 256 KiB by default. Larger payloads go to S3 with a pointer in the message - never inline a document.
  • Long polling (receive_wait_time_seconds = 20) cuts both empty receives and cost. Set it.

Comparison

DB queueRedisRabbitMQKafkaSQS
Transactional enqueueYes, freeNoNoNoNo
Operational burdenNone extraLowMediumHighNone
Realistic throughputThousands/secTens of thousands/secTens of thousands/secVery highEffectively unlimited
Per-message retryYesYesYesManualYes
Delayed jobsTrivialBuilt inPluginManualUp to 15 min, or a timer
RoutingManualBasicExcellentBy topic and partitionVia SNS
Replay historyYes, it is a tableNoNoYes, nativelyNo
OrderingBy queryBy listPer queuePer partitionFIFO queues only
DebuggabilitySQLGood dashboardsManagement UIHarderConsole, plus your logs
The row that decides it for most teams is the first one. If you already run Postgres and generate fewer than a few thousand jobs a second, a database queue removes an entire class of bug and an entire system to operate.

Workflow engines, and when a job is really a process

There is a category of work that is not one job. Charge the card, provision the account, send the welcome email, schedule a follow-up in seven days, wait for the customer to confirm. Modelling that as five queued jobs means you own the state machine, the compensation logic, and the answer to "where did this get to?"

Durable execution engines invert that. You write the process as ordinary sequential code; the engine persists progress at each step and resumes from the last completed one after a crash, a deploy, or a seven-day sleep.

ts
// A Temporal-style workflow: linear code, durable across failures
export async function onboardCustomer(input: OnboardInput) {
  const payment = await activities.chargeCard(input.paymentMethod, input.cents)

  const account = await activities.provisionAccount(input.email)

  await activities.sendWelcomeEmail(account.id)

  // The process sleeps for a week. No worker is blocked; no timer table is yours.
  await sleep('7 days')

  if (!(await activities.hasCompletedSetup(account.id))) {
    await activities.sendNudgeEmail(account.id)
  }

  return { accountId: account.id, paymentId: payment.id }
}
If the process crashes after provisionAccount, it resumes at sendWelcomeEmail on restart. The card is not charged again - that step's result is in the history.

The costs, honestly

  • Workflow code must be deterministic. No Date.now(), no Math.random(), no direct I/O in workflow bodies - all of that goes in activities. Replay is how recovery works, and non-deterministic code breaks replay.
  • There are hard limits on history. Temporal caps event history at 51,200 events or 50 MB per execution, with payloads up to 2 MB. Long-running workflows must checkpoint with Continue-As-New.
  • Versioning is a real discipline. A workflow started last week is still replaying against the code you are about to change. Every engine has a versioning mechanism and you have to use it.
  • Temporal does not run your code. You deploy and operate the worker fleet that polls task queues, even on Temporal Cloud. The service orchestrates; your infrastructure executes.
  • It is more platform than a simple job needs. For a webhook handler or a nightly report, this is enormous overhead for no benefit.

The landscape

OptionModelFits when
TemporalWorker fleet polling task queues; the reference implementationComplex, long-lived, mission-critical processes and you can run the workers
DBOSPostgres as the durability layer, no separate clusterYou already trust Postgres and want minimal operational footprint
RestateDurable services, timers and state with a lighter programming modelYou want durability around ordinary backend code
Inngest / Trigger.devSteps invoked in your app over HTTPServerless codebases with nothing new to run
AWS Step FunctionsState machine defined in JSON, steps on AWS computeYou are on AWS and the process maps to a state machine
Cloudflare WorkflowsDurable multi-step execution on WorkersYou are already on Workers
The deepest split is where your steps run: your own worker fleet, a specific platform's compute, or inside your existing app over HTTP. That, more than features, determines which fits your architecture.

Operating the thing

What to monitor

MetricWhy it matters
Queue depth per job typeThe headline health indicator. Growing depth means throughput is below arrival rate
Oldest message ageBetter than depth. A queue of 10 with a 3-hour-old message is worse than a queue of 10,000 moving fast
Processing duration percentilesp99 approaching your visibility timeout means imminent double processing
Retry rate by job type and error classWhere the instability is, before it becomes dead letters
Dead-letter depth and rateAlert at greater than zero. Always
Worker concurrency versus capacityAre you starved of workers, or of downstream capacity?
Duplicate-skipped countConfirms idempotency is actually firing, which is otherwise invisible

Practicalities that bite

  • Separate queues by priority and by job type. One slow job type in a shared queue starves everything behind it. Separate queues, separate worker pools, separate alerting.
  • Handle SIGTERM properly. Stop claiming new jobs, finish the current one, release any partially-processed job back to the queue, then exit. Deploys are the most common cause of duplicate execution.
  • Keep payloads small - pass IDs, not documents. The row may have changed by the time the job runs, and you usually want the current value rather than a snapshot from enqueue time.
  • Version your job payloads. A worker running old code will receive messages produced by new code during every rolling deploy.
  • Set a global timeout per job. A job with no timeout that hangs on a socket holds a worker slot indefinitely.
  • Test the failure paths. Kill a worker mid-job. Enqueue a duplicate deliberately. Force a job into the DLQ and redrive it. These paths are only exercised in production unless you exercise them deliberately.

Mistakes that keep recurring

MistakeConsequence
Enqueuing inside a transaction, to an external queueJobs referencing rows that were rolled back
Assuming exactly-once deliveryDuplicate charges, duplicate emails, corrupted counters
Retrying without classifying the errorTwenty attempts at a permanently malformed payload
No jitter on backoffSynchronised retries knock the recovering service back over
Visibility timeout shorter than p99 durationThe queue redelivers work still in progress
Incrementing attempts after processingA crashing payload loops forever and takes workers with it
Nobody watching the dead-letter queueSilent, permanent data loss with a paper trail nobody reads
Redis queue with an eviction policy setJobs silently deleted under memory pressure
Redis queue with RDB-only persistenceA crash loses every job since the last snapshot
Kafka used as a job queueOne stuck message blocks its whole partition
Large payloads in the messageBroker limits hit, and stale data processed
No graceful shutdownEvery deploy duplicates or drops in-flight work
Retry loops at multiple layersThree times five is fifteen calls nobody planned for

Verdict

Background jobs look like the simplest part of a system and are among the least forgiving, because every guarantee you assume without checking becomes a bug that shows up in production as money or trust.

Three things carry most of the weight. Enqueue transactionally, either by using your database as the queue or by using an outbox. Make every handler idempotent, keyed on the business event rather than the message. And treat the dead-letter queue as a workflow with alerting and a redrive path, not a place messages go to be forgotten.

Assume every job will run twice, in the wrong order, on a worker that dies halfway. If the system is still correct under that assumption, you have built it properly.

Our test for any job pipeline

On choosing: if you already run Postgres and generate fewer than a few thousand jobs a second, use a database queue and delete an entire system from your architecture. Reach for Redis when latency and throughput demand it, RabbitMQ when routing is the real requirement, SQS when you are on AWS and want to operate nothing, Kafka only when you genuinely need a replayable event log - and a workflow engine when what you are modelling is not a job at all, but a process with steps, waits and state.

Sources

Back to Blog
Share:

Related Posts