Articles › Programming

Background jobs and queues: retries, backoff and dead-letter without duplicates

Email, image resizing and AI calls do not belong in the request. A Postgres SKIP LOCKED job queue with backoff, visibility timeouts, dead-letter and idempotent jobs.

Intermediate 10 min read October 22, 2026

What you will learn
  • Slow work or anything that depends on an outside service becomes a job; the request records it and returns at once
  • Almost every queue delivers at least once, so a job must be safe to run twice (idempotent)
  • Retry with capped exponential backoff plus jitter and a maximum attempt count, then park the job in a dead-letter for a human
  • The simplest start: a jobs table in the Postgres you already have, with FOR UPDATE SKIP LOCKED so workers never collide

A user clicks “sign up” and the page spins for two seconds while the request sends an email and resizes an avatar. Worse, SMTP goes down for five minutes and nobody can sign up at all. The fix is a background job: the request records the work and returns, and another process does it later. This article covers what makes a queue trustworthy and builds a working jobs table on Postgres.

Every SQL block was tested on PostgreSQL 18.6 (run through PGlite 0.5.11). SQS, BullMQ and Sidekiq defaults were checked against their official docs as of October 2026.

Why slow work leaves the request

Say a sign-up handler spends its time like this:

StepTime
Save the user40 ms
Send a confirmation email over SMTP1,200 ms
Resize the avatar800 ms
Total2,040 ms

Turn the last two into jobs and the request is the user insert plus two job rows in the same transaction, about 40 ms and change: roughly 50 times faster (2,040 ÷ 40 ≈ 51). More important than speed, an SMTP outage no longer blocks sign-ups. The job waits and retries on its own.

Good candidates: notifications, image and video processing, slow AI API calls, PDFs, outgoing webhooks. Anything the user must see right now (a password check, reserving stock) stays in the request.

The parts of a trustworthy queue

TermMeaning
producercode that enqueues a job (usually the request handler)
workera process that takes jobs and runs them; run as many as you like
at-least-oncethe queue promises a job runs at least once, possibly more
visibility timeout / leasehow long a job stays reserved by one worker; if it is not marked done in time, it reappears for another worker
retry + backoffon failure, try again, waiting longer each time
dead-letterwhere jobs go after their last failed attempt, waiting for a human

At-least-once means a job must survive running twice

A worker sends the email, then dies before recording success. The queue only sees an expired lease, so another worker runs the job again: two emails. No queue can fix this on its side; the SQS docs say even the visibility timeout does not guarantee a message is delivered only once.

The fix lives in the job: make it idempotent, so running it many times has the same effect as running it once.

  • Image resizing: write to a path derived from the input, e.g. avatars/{user_id}/256.webp. A rerun overwrites the same file
  • Charging money at a provider: use its idempotency key. Stripe’s Idempotency-Key header returns the saved result on a repeat instead of creating a second object (keys may be pruned after 24 hours). Use the job or order id
  • Email has no such key, so pick your failure: mark “sent” first and you may never send; mark it after and you may send twice. For receipts, a duplicate beats a missing one

The queue promises “at least once”. “The effect happens once” is the job’s responsibility.

readywaitingrunning5-min lease · expired = reclaimeddonesucceededfailed: backoff waitback to readydeadmax_attempts hit · needs a human
A job loops through backoff retries until it succeeds or runs out of attempts and lands in dead-letter

A jobs table on Postgres with SKIP LOCKED

If you already run Postgres, there is nothing to install. FOR UPDATE SKIP LOCKED makes each worker skip rows another worker has locked instead of waiting. The Postgres docs describe it as suited to queue-like tables with multiple consumers, and it is the mechanism behind pg-boss (Node) and Solid Queue (the default in new Rails 8 apps).

CREATE TABLE jobs (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  kind         text        NOT NULL,
  payload      jsonb       NOT NULL,
  status       text        NOT NULL DEFAULT 'ready',  -- ready | running | done | dead
  attempts     int         NOT NULL DEFAULT 0,
  max_attempts int         NOT NULL DEFAULT 8,
  run_at       timestamptz NOT NULL DEFAULT now(),
  locked_until timestamptz,
  last_error   text,
  dedupe_key   text UNIQUE
);
CREATE INDEX jobs_due ON jobs (run_at) WHERE status IN ('ready', 'running');

Enqueue in the same transaction as the business data. This is the big advantage of a queue in the same database: the order commits, the job exists; the order rolls back, the job is gone too. There is no “order saved, but the job never reached Redis” case.

BEGIN;
INSERT INTO orders (id, total) VALUES (1042, 59000);
INSERT INTO jobs (kind, payload, dedupe_key)
VALUES ('send_receipt', '{"order_id": 1042}', 'receipt:1042')
ON CONFLICT (dedupe_key) DO NOTHING;
COMMIT;

dedupe_key prevents enqueuing a duplicate (a double click, say). It does not prevent running twice; that still needs idempotency.

A worker claims a job, reserving it with locked_until, which plays the role of the visibility timeout, and commits immediately, so no transaction stays open while the work runs. A job whose lease expired (its worker died) gets claimed again.

UPDATE jobs
SET status = 'running',
    attempts = attempts + 1,
    locked_until = now() + interval '5 minutes'
WHERE id = (
  SELECT id FROM jobs
  WHERE attempts < max_attempts
    AND ((status = 'ready'   AND run_at <= now())
      OR (status = 'running' AND locked_until < now()))
  ORDER BY run_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
RETURNING id, kind, payload, attempts;

Success and failure (backoff of 30 seconds doubling each time, capped at 1 hour, multiplied by a 0.5-1.0 jitter; after max_attempts the job is dead):

-- success
UPDATE jobs SET status = 'done', locked_until = NULL WHERE id = $1;

-- failure: $1 = error text, $2 = id
UPDATE jobs
SET last_error   = $1,
    locked_until = NULL,
    status = CASE WHEN attempts >= max_attempts THEN 'dead' ELSE 'ready' END,
    run_at = now() + least(interval '1 hour',
                           interval '30 seconds' * power(2, attempts - 1))
                     * (0.5 + random() / 2)
WHERE id = $2;

The worker in Python with psycopg 3 (swap $1, $2 for %s, in order) is this short:

import time, psycopg

conn = psycopg.connect("dbname=app", autocommit=True)  # each statement commits on its own

while True:
    job = conn.execute(CLAIM_SQL).fetchone()
    if job is None:
        time.sleep(1)
        continue
    job_id, kind, payload, attempts = job
    try:
        HANDLERS[kind](payload)          # one function per job kind; must be idempotent
        conn.execute(DONE_SQL, (job_id,))
    except Exception as e:
        conn.execute(FAIL_SQL, (repr(e)[:500], job_id))

A job that kills the worker every time (a corrupt image that crashes the library, say) never reaches FAIL. Sweep for those from cron every minute:

UPDATE jobs SET status = 'dead', last_error = coalesce(last_error, 'lease expired')
WHERE status = 'running' AND locked_until < now() AND attempts >= max_attempts;

Worked example: how many retries are enough?

Say your email provider’s SMTP is down for 20 minutes. With the backoff above (before jitter), the wait after failure n is 30 × 2^(n-1) seconds.

Failure #WaitCumulative
130 s30 s
260 s1.5 min
3120 s3.5 min
4240 s7.5 min
5480 s15.5 min
6960 s31.5 min
71,920 s63.5 min
  • max_attempts = 5: attempt 5 runs at minute 7.5, the outage is still on, and every job goes dead. Someone has to retry them by hand
  • max_attempts = 8: attempt 8 runs at 3,810 seconds ≈ 63.5 minutes. Even if jitter rolls the minimum (×0.5) every time, that is ≈ 31.75 minutes, safely past a 20-minute outage

Jitter matters: 500 emails that fail together would, without it, all retry at the same instant every round. The AWS Builders’ Library explains that randomising the delay is what stops retries becoming a fresh wave of overload.

How many workers: an AI summary job takes 8 seconds and 600 arrive an hour. That is 600 × 8 = 4,800 seconds of work per hour ÷ 3,600 = 1.33 workers, so at least 2. If 300 jobs arrive at once and 4 workers run, the queue clears in 300 × 8 ÷ 4 = 600 seconds = 10 minutes. Check that the API’s rate limit allows 4 concurrent calls.

The visibility timeout must outlast the work

Set the lease from the slowest acceptable run, not the average. A 6-minute job with a 5-minute lease gets picked up by a second worker while the first is still busy. For long, unpredictable jobs, extend the lease periodically (a heartbeat), as SQS recommends with ChangeMessageVisibility. Too long hurts too: when a worker dies, its job sits idle for the whole lease.

For reference: SQS defaults to 30 seconds, with a maximum of 12 hours from first receipt.

Dead-letter: the place a human must look

A job that fails every attempt is usually a bug or bad data, and more retries will not fix it. So it is parked (status = 'dead' here, the Dead set in Sidekiq, a DLQ in SQS once maxReceiveCount is exceeded). It must come with an alert: a dead-letter nobody watches is silent data loss. Feed SELECT count(*) FROM jobs WHERE status = 'dead' into a Telegram server alert, and always keep last_error.

Postgres or Redis?

Postgres + SKIP LOCKEDRedis-based (BullMQ, Sidekiq)
Extra infrastructurenone, your existing databasea Redis instance
Enqueue with business dataone transactiontwo systems; beware “committed, but the job never got sent”
Built-in retrieswrite them (above) or use pg-boss / Solid QueueBullMQ: attempts + backoff 2^(attempts-1) × delay; Sidekiq: 25 retries over about 20 days
Dead-letterstatus = 'dead'Sidekiq Dead set (keeps 10,000 jobs or 6 months)
Must configureindex, purge old done jobsAOF on, maxmemory-policy noeviction

Already on Postgres? Start with a jobs table. Already running Redis for a hot-path cache and writing Node? BullMQ is fine, but configure Redis as a data store, not a cache.

Common mistakes

  • Holding a transaction open while the job runs: rows stay locked and a connection is tied up. Claim, commit, then work
  • Retrying instantly with no backoff, hammering a service that is already down
  • Retrying forever: a permanently broken job burns resources indefinitely
  • Believing the queue delivers once, then writing a job that can charge twice
  • Enqueuing to another system before the data commits: the worker cannot find the order yet
  • Never deleting finished jobs: delete done rows older than 7 days nightly
  • A lease shorter than the slowest run: jobs quietly run twice in parallel

Sources

FAQ

Why not make the queue deliver exactly once?

A worker can finish the work and die before telling the queue. The queue cannot know whether the work happened, so it must either redeliver (risking a duplicate) or not (risking a loss). Most systems redeliver and make the job itself absorb duplicates.

How far does Postgres go as a queue?

100,000 jobs a day averages about 1.2 jobs a second (100,000 ÷ 86,400), light work for a database you already run. The big win is enqueuing in the same transaction as your business data. If jobs ever start to hurt the main database, move to Redis or a dedicated queue then.

Can a Redis-based queue lose jobs on restart?

Yes, without persistence. The BullMQ docs recommend enabling AOF and setting maxmemory-policy to noeviction, because a queue breaks if Redis evicts keys when memory fills.

Commands and settings were tested on sample systems. Try them on a test machine before production.