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.
| Guarantee | What it means | Reality |
|---|---|---|
| At-most-once | Delivered zero or one times | Fire and forget. Messages are lost on failure |
| At-least-once | Delivered one or more times | What you actually have. Duplicates are normal, not exceptional |
| Exactly-once delivery | Delivered precisely once | Not achievable across a network. Anyone claiming it means something narrower |
| Exactly-once processing | The effect happens once, even if delivery repeats | Achievable - 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.
// 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 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.
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;// 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() },
})
}
}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
-- 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;2. Deduplicate on a job key
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
}
}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.
CREATE TABLE job_executions (
idempotency_key text PRIMARY KEY,
job_type text NOT NULL,
result jsonb,
completed_at timestamptz NOT NULL DEFAULT now()
);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' },
})
})
}Where the key comes from
- Derive it from the business event, not from the job's own id.
charge:order_874:attempt_1deduplicates 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_42blocks the second genuine email to that user;send_email:user_42:order_874:shippeddoes 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
| Class | Examples | Action |
|---|---|---|
| Transient | Network timeout, 503, connection reset, deadlock | Retry with backoff. This is what retries are for |
| Rate limited | 429, quota exceeded | Retry, but respect Retry-After rather than your own schedule |
| Permanent | 400, validation failure, malformed payload | Do not retry. Fail straight to the dead-letter queue |
| Not-yet | A referenced row does not exist yet | Retry briefly, then treat as permanent - it may never arrive |
| Unknown | An unmapped exception | Retry a limited number of times, then dead-letter and alert |
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
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- 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.
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 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.
-- 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
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;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,
BRPOPandBLMOVEgive 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-lruon the instance holding your queue will silently delete jobs under memory pressure. Queue data belongs on an instance withnoevictionand 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 queue | What Kafka gives you |
|---|---|
| Acknowledge one message, retry it alone | An offset. One stuck message blocks its partition |
| Delay a retry by five minutes | Nothing built in - you write retry topics |
| Per-job priority | Not a concept |
| Dead-letter one message | You publish it to another topic yourself |
| Thousands of independent consumers | Parallelism is capped by partition count |
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.
| Standard | FIFO | |
|---|---|---|
| Throughput | Nearly unlimited | 300 API actions/sec, 3,000 messages/sec with batching of 10 |
| Ordering | Best effort | Strict, within a MessageGroupId |
| Delivery | At-least-once, duplicates possible | Marketed as exactly-once processing; still redelivers on a crash before delete |
| Parallelism | Unconstrained | One in-flight message per group at a time |
| In-flight limit | 120,000 | 20,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.
ChangeMessageVisibilityapplies 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 queue | Redis | RabbitMQ | Kafka | SQS | |
|---|---|---|---|---|---|
| Transactional enqueue | Yes, free | No | No | No | No |
| Operational burden | None extra | Low | Medium | High | None |
| Realistic throughput | Thousands/sec | Tens of thousands/sec | Tens of thousands/sec | Very high | Effectively unlimited |
| Per-message retry | Yes | Yes | Yes | Manual | Yes |
| Delayed jobs | Trivial | Built in | Plugin | Manual | Up to 15 min, or a timer |
| Routing | Manual | Basic | Excellent | By topic and partition | Via SNS |
| Replay history | Yes, it is a table | No | No | Yes, natively | No |
| Ordering | By query | By list | Per queue | Per partition | FIFO queues only |
| Debuggability | SQL | Good dashboards | Management UI | Harder | Console, plus your logs |
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.
// 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 }
}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(), noMath.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
| Option | Model | Fits when |
|---|---|---|
| Temporal | Worker fleet polling task queues; the reference implementation | Complex, long-lived, mission-critical processes and you can run the workers |
| DBOS | Postgres as the durability layer, no separate cluster | You already trust Postgres and want minimal operational footprint |
| Restate | Durable services, timers and state with a lighter programming model | You want durability around ordinary backend code |
| Inngest / Trigger.dev | Steps invoked in your app over HTTP | Serverless codebases with nothing new to run |
| AWS Step Functions | State machine defined in JSON, steps on AWS compute | You are on AWS and the process maps to a state machine |
| Cloudflare Workflows | Durable multi-step execution on Workers | You are already on Workers |
Operating the thing
What to monitor
| Metric | Why it matters |
|---|---|
| Queue depth per job type | The headline health indicator. Growing depth means throughput is below arrival rate |
| Oldest message age | Better than depth. A queue of 10 with a 3-hour-old message is worse than a queue of 10,000 moving fast |
| Processing duration percentiles | p99 approaching your visibility timeout means imminent double processing |
| Retry rate by job type and error class | Where the instability is, before it becomes dead letters |
| Dead-letter depth and rate | Alert at greater than zero. Always |
| Worker concurrency versus capacity | Are you starved of workers, or of downstream capacity? |
| Duplicate-skipped count | Confirms 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
SIGTERMproperly. 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
| Mistake | Consequence |
|---|---|
| Enqueuing inside a transaction, to an external queue | Jobs referencing rows that were rolled back |
| Assuming exactly-once delivery | Duplicate charges, duplicate emails, corrupted counters |
| Retrying without classifying the error | Twenty attempts at a permanently malformed payload |
| No jitter on backoff | Synchronised retries knock the recovering service back over |
| Visibility timeout shorter than p99 duration | The queue redelivers work still in progress |
| Incrementing attempts after processing | A crashing payload loops forever and takes workers with it |
| Nobody watching the dead-letter queue | Silent, permanent data loss with a paper trail nobody reads |
| Redis queue with an eviction policy set | Jobs silently deleted under memory pressure |
| Redis queue with RDB-only persistence | A crash loses every job since the last snapshot |
| Kafka used as a job queue | One stuck message blocks its whole partition |
| Large payloads in the message | Broker limits hit, and stale data processed |
| No graceful shutdown | Every deploy duplicates or drops in-flight work |
| Retry loops at multiple layers | Three 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.
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
- Amazon SQS developer guide - visibility timeouts, FIFO semantics, dead-letter queues and limits
- PostgreSQL
SELECT … FOR UPDATE SKIP LOCKED- the primitive behind every database queue - Transactional outbox pattern - the canonical description
- Temporal documentation - workflows, activities, determinism, event history limits and Continue-As-New
- RabbitMQ dead letter exchanges - routing failures without losing them
- Kafka design - the log model, and why it is not a job queue
- BullMQ - a well-documented Redis queue implementation



