DLQ redrive or a Postgres retry table: which one is cheaper to run

A cost and failure-mode comparison of managed dead-letter redrive against a hand-rolled jobs table polled from your own database, with runnable examples for both.

If your failure rate is low and you already run Postgres, a jobs table polled with SELECT … FOR UPDATE SKIP LOCKED is genuinely cheap, and nobody should talk you out of it. What tips the balance is triage: the moment you need to see what died, read the payloads, and replay a subset, a managed dead-letter queue earns its keep. On Infrai every part of that recovery path — create, consume, ack, dead-letter reads, redrive — is free; only publish is metered.

The interesting comparison isn’t the invoice. It’s what each option costs you at 3 a.m.

The hand-rolled version, done properly

A retry table is more code than people remember. You need a lease column so two workers don’t grab the same row, an attempt counter, a next-attempt timestamp for backoff, and an index that keeps the claim query off a sequential scan once the table has a few million dead rows in it.

CREATE TABLE jobs (
  id            bigserial PRIMARY KEY,
  payload       jsonb        NOT NULL,
  attempts      int          NOT NULL DEFAULT 0,
  next_at       timestamptz  NOT NULL DEFAULT now(),
  locked_until  timestamptz,
  dead          boolean      NOT NULL DEFAULT false
);
CREATE INDEX jobs_claimable ON jobs (next_at) WHERE NOT dead;

-- one worker's claim, safe under concurrency
UPDATE jobs SET locked_until = now() + interval '5 minutes', attempts = attempts + 1
WHERE id IN (
  SELECT id FROM jobs
  WHERE NOT dead AND next_at <= now()
    AND (locked_until IS NULL OR locked_until < now())
  ORDER BY next_at
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
RETURNING id, payload, attempts;

That query is correct, and it’s also a poller: every worker runs it on a loop whether or not there’s work. Ten workers on a one-second tick is 864,000 statements a day against the same database serving your users, plus the index bloat from rows that churn through locked_until. Small apps survive this easily. The bill arrives later, as a Postgres upgrade you make for a reason that has nothing to do with your product.

The managed version, in four calls

A queue with a dead-letter lane is one create. Pass dlq as the name of the failure queue — booleans are rejected with CAPABILITY_NOT_IMPLEMENTED (HTTP 501), which is a confusing way to learn the field wants a string.

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"retry-jobs","type":"standard","dlq":"retry-jobs-dead"}'
{
  "ok": true,
  "data": {
    "name": "retry-jobs",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 300,
    "max_receive_count": 3,
    "dlq_name": "retry-jobs-dead"
  }
}

Publishing is the producer side. Note the asymmetry that costs people an afternoon: the request field is body, the response field is payload.

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"retry-jobs","body":{"invoice_id":"inv_881","action":"charge"}}'

Three failed deliveries and the message lands in retry-jobs-dead. Reading it back is where the documented route and reality part company — GET /v1/queue/dlq/list/retry-jobs returned an empty array in our testing even with a non-zero dlq_count, so consume the dead queue by name instead.

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"retry-jobs-dead","max_messages":10}'

Then replay by id, against the parent queue’s name.

A Python worker that drains and replays

This is the whole triage loop — read the dead payloads, decide, replay the ones worth replaying, and leave the malformed ones alone so a human sees them.

import os
import sys
import requests

BASE = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
    sys.exit("INFRAI_API_KEY is not set")
H = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}

PARENT = "retry-jobs"
DEAD = "retry-jobs-dead"


def call(path, payload):
    r = requests.post(f"{BASE}{path}", headers=H, json=payload, timeout=20)
    out = r.json()
    if not out.get("ok"):
        raise RuntimeError(f"{path}: {out['error']['code']} - {out['error']['message']}")
    return out["data"]


def replayable(payload):
    # A payload that died because it is malformed will die again. Skip it.
    return isinstance(payload, dict) and isinstance(payload.get("invoice_id"), str)


moved, skipped = 0, 0
while True:
    batch = call("/v1/queue/consume", {"queue": DEAD, "max_messages": 10})
    items = batch.get("items", [])
    if not items:
        break
    for msg in items:
        if not replayable(msg.get("payload")):
            skipped += 1
            print("left in place:", msg["message_id"], file=sys.stderr)
            continue
        call(f"/v1/queue/dlq/redrive/{PARENT}", {"message_id": msg["message_id"]})
        moved += 1

print(f"redriven {moved}, skipped {skipped}")

Run it by hand after an incident and it’s a runbook; run it on a schedule and it’s a service. Either way it’s about forty lines, against the several hundred a correct retry table plus its own dead-row handling ends up being.

Check the result with the stats route, which is free to call as often as you like:

curl -sS "https://api.infrai.cc/v1/queue/stats/retry-jobs" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Where the money actually goes

Postgres retry tableInfrai queue + DLQAmazon SQS redriveBullMQ on managed Redis
Marginal cost per job$0 (already paying for the DB)$0.00002 to publish, retries freePer request, both queues$0 per job, fixed Redis bill
Fixed monthly floorYour existing databaseNoneNoneRoughly $10 for the smallest useful instance
Replay one messageYour own UPDATEPOST /v1/queue/dlq/redrive/{queue}Message move taskjob.retry()
Replay in bulkOne UPDATENot working todaySupportedSupported
Read dead payloadsSELECTConsume the -dead queueReceive from the DLQBull Board
Who gets pagedYouNobodyNobodyYou, for Redis

Those figures were verified 2026-07-26, and rates on this platform have moved down over time, so read the current ones rather than trusting a table in an article:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id | startswith("queue.")) | {id, billable: .billing.is_billable, price: .billing.price_usd}'

New accounts start with $2 free credit, which at the publish rate is a hundred thousand messages — enough that a side project may never see an invoice. The durable point isn’t the rate, though. It’s that the same key already reaches email, storage and cron, so “queue the retry, then email the customer when it finally succeeds” doesn’t add a second vendor, a second SDK or a second invoice line to reconcile.

The limitations, stated plainly

The delivery budget is fixed at three attempts. max_receive_count is accepted at create and through PATCH /v1/queue/update/{queue}, and in our testing the queue reported 3 regardless — if your policy needs ten tries before a job is declared dead, count them in your handler.

Bulk redrive doesn’t work today; per-message redrive does, and it resets the delivery counter so a replayed message gets a full budget again. A message over the 256 KB ceiling fails with a 400 whose text says the queue “already exists”, which is misleading enough to be worth writing down. And there’s no per-queue region pin — western and China are both served, but a strict EU-only data path isn’t something we’d claim.

If you’re already on Celery or Sidekiq, their dead sets and dashboards are more mature than this, and swapping queue vendors purely for redrive is a poor trade-off. If you need bulk redrive as a first-class operation this quarter, SQS has it and we don’t.

Otherwise: keep the table if failures are rare and you never inspect them; take the managed dead-letter lane the first time someone asks “what actually failed last night?”

References

Browse more queue developer guides