Dead-Letter Queues for Undeliverable Push Messages

A push job has burned its retry budget and still has not been accepted. Deleting it loses the evidence; retrying it forever poisons the queue. The dead-letter queue is where it goes — but only if it belongs to one of the two failure classes that actually warrant one.

Quick Answer

A DLQ is for messages whose failure is unresolved, not for messages whose failure is resolved by deletion. Two classes qualify: payloads the push service will never accept (poison), and transient failures that outlived their retry budget. Everything else has a correct terminal action that is not “park it somewhere”.

Response Class Correct action Reaches the DLQ?
400 Bad Request Malformed request or headers Park for engineering triage Yes — poison
413 Payload Too Large Payload over the 4 KB aes128gcm limit Park; the payload builder is broken Yes — poison
401 / 403 VAPID JWT expired, wrong audience, or key mismatch Fix credentials, requeue the whole affected batch No — fleet-wide fault
404 Not Found Endpoint no longer exists Delete the subscription row Never
410 Gone Subscription permanently unsubscribed Delete the subscription row Never
429 Too Many Requests Origin throttled Honour Retry-After, return to the main queue No
500 / 502 / 503 Push service transient fault Exponential backoff; DLQ only when the budget is spent Yes — after retries
Network error / timeout Transport fault Same as 5xx Yes — after retries
Routing a failed push send by status class A failed send attempt branches into five status classes. 400 and 413 are poison and go to the dead-letter queue. 401 and 403 are credential faults fixed fleet-wide and requeued. 404 and 410 are terminal deletes that never reach the dead-letter queue. 429 is throttled and returns to the main queue after Retry-After. 5xx and network errors back off and reach the dead-letter queue only when the retry budget is exhausted. Send attempt failed 400 / 413 401 / 403 404 / 410 429 5xx / network Poison payload park for triage never retry as-is Credential fault fix VAPID, requeue whole batch Terminal delete drop subscription never dead-letter Throttled Retry-After + jitter back to main queue Transient backoff to budget then dead-letter Dead-letter queue: poison payloads and retry-exhausted transients only A DLQ that fills with 410s is not a DLQ — it is a subscription table that nobody is pruning.
Only two of the five failure classes have a legitimate route into the dead-letter queue. The red crosses are the mistakes that make a DLQ useless.

The 410 Gone rule is the one teams get wrong most often. A 410 is a successful interaction: the push service told you the truth about a subscription that no longer exists. The correct handler deletes the row, as covered in handling 410 Gone responses at scale. Routing it to a DLQ converts a solved problem into a growing backlog that masks real failures — and after a large-scale browser reinstall event, that backlog can be millions of rows deep.

The DLQ Record

Store enough to triage and replay, and nothing more. In particular store a reference to the payload, not the payload: a DLQ that holds plaintext notification bodies is a long-lived copy of personal data with none of the retention controls your primary store has.

CREATE TABLE push_dlq (
  dlq_id            BIGSERIAL PRIMARY KEY,
  message_id        UUID        NOT NULL,          -- joins back to push_dispatch
  subscription_id   BIGINT      NOT NULL,
  endpoint_host     TEXT        NOT NULL,          -- fcm.googleapis.com, updates.push.services.mozilla.com
  payload_ref       TEXT        NOT NULL,          -- object-store key, encrypted at rest
  attempt_count     SMALLINT    NOT NULL,
  first_attempt_at  TIMESTAMPTZ NOT NULL,
  last_attempt_at   TIMESTAMPTZ NOT NULL,
  terminal_status   SMALLINT,                      -- NULL for transport errors
  error_class       TEXT        NOT NULL,          -- 'poison' | 'retry_exhausted'
  error_code        TEXT        NOT NULL,          -- '413' | 'ECONNRESET' | 'jwt_audience'
  error_snippet     TEXT,                          -- first 512 bytes of the response body
  original_ttl      INTEGER     NOT NULL,
  expires_at        TIMESTAMPTZ NOT NULL,          -- first_attempt_at + original_ttl
  replay_state      TEXT        NOT NULL DEFAULT 'parked',  -- parked|claimed|replayed|discarded
  replay_key        UUID        NOT NULL,          -- idempotency token for the re-send
  replay_count      SMALLINT    NOT NULL DEFAULT 0,
  parked_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX push_dlq_triage    ON push_dlq (error_class, error_code, endpoint_host, parked_at DESC);
CREATE INDEX push_dlq_replayable ON push_dlq (replay_state, expires_at) WHERE replay_state = 'parked';
CREATE UNIQUE INDEX push_dlq_once ON push_dlq (message_id, subscription_id);

expires_at is the field that separates a working DLQ from a dangerous one. It is computed once, from the original dispatch time and the original TTL, and it never moves. Nothing else in the replay path is allowed to overwrite it.

Anatomy of a dead-letter record A record card lists thirteen fields grouped into four bands: identity fields message_id, subscription_id, endpoint_host and payload_ref; attempt history fields attempt_count, first_attempt_at, last_attempt_at and terminal_status; classification fields error_class, original_ttl and expires_at; and replay fields replay_state and replay_key. Four callout panels on the right explain the purpose of each band, including that payload_ref points at encrypted storage rather than holding the payload itself. Anatomy of a dead-letter record push_dlq message_id subscription_id endpoint_host payload_ref attempt_count first_attempt_at last_attempt_at terminal_status error_class original_ttl expires_at replay_state replay_key Identity: message_id joins back to the dispatch ledger and is never regenerated on replay. History: attempt_count and the timestamps feed poison detection and alert thresholds. TTL gate: expires_at is written once and is the only thing standing between replay and staleness. Replay: replay_key makes a re-send idempotent; payload_ref keeps plaintext out of the DLQ.
Four field bands, four jobs: identify, explain, gate on freshness, and make the replay safe to run twice.

Poison-Message Detection

A poison message is one that will fail identically on every attempt. Detect it before the retry budget is spent, because retrying a 413 eleven times costs eleven signed JWTs and eleven round trips to learn nothing.

Classify on the first response, not on attempt count:

  • Deterministic status. 400 and 413 are deterministic for a fixed payload. Route straight to the DLQ with error_class = 'poison' and attempt_count = 1. Never apply backoff to them; the exponential backoff path is for faults that can heal on their own.
  • Repeated identical error. Two attempts with the same error_code and the same response snippet, separated by a real backoff interval, is a poison signal even for a nominally transient class. Promote to poison at attempt 3 rather than waiting for attempt 10.
  • Endpoint-scoped clustering of one error code. If 413 appears for 100% of one campaign and 0% of every other campaign, the payload builder for that campaign is broken. Group by (error_code, campaign_id) in triage, not by subscription.
  • Serialisation crashes. A job that kills its worker before any HTTP request is made — an unbounded template expansion, a circular reference during JSON encoding — must be caught by the worker and parked with terminal_status = NULL. Without that catch it is redelivered forever by the broker’s visibility timeout and takes a worker down each time.

Replay With Idempotency and a TTL Gate

Replay is where DLQs cause outages. The failure mode is always the same: an operator drains a two-day-old DLQ and 40,000 users receive “Your code is 448120” for a code that expired on Tuesday.

Four gates, in order, and every replay passes all four.

// replay.js — drains parked DLQ records through four gates.
const REPLAY_RATE = 0.1;          // fraction of live send capacity
const CIRCUIT_BREAK_AFTER = 5;    // consecutive failures before abort

async function replayBatch(db, sender, { limit = 500 }) {
  const rows = await db.claimParked(limit); // sets replay_state='claimed' atomically
  const outcome = { replayed: 0, stale: 0, gone: 0, duplicate: 0, failed: 0 };
  let consecutiveFailures = 0;

  for (const row of rows) {
    // Gate 1 — freshness. expires_at was fixed at first dispatch and never moved.
    if (new Date(row.expires_at) <= new Date()) {
      await db.close(row.dlq_id, 'discarded', 'ttl_expired');
      outcome.stale += 1;
      continue;
    }

    // Gate 2 — the endpoint must still exist. A replay is not a reason to
    // resurrect a subscription that was deleted after a 410.
    const sub = await db.getActiveSubscription(row.subscription_id);
    if (!sub) {
      await db.close(row.dlq_id, 'discarded', 'subscription_gone');
      outcome.gone += 1;
      continue;
    }

    // Gate 3 — idempotency. replay_key is unique per DLQ record; the dispatch
    // table rejects a second insert with the same key.
    const claimed = await db.claimReplayKey(row.replay_key);
    if (!claimed) {
      await db.close(row.dlq_id, 'discarded', 'already_replayed');
      outcome.duplicate += 1;
      continue;
    }

    // Gate 4 — remaining TTL, not original TTL. A message parked for 3 hours
    // of a 6-hour TTL is re-sent with 3 hours left, never with 6.
    const remainingTtl = Math.floor((new Date(row.expires_at) - Date.now()) / 1000);
    try {
      await sender.send(sub, await loadPayload(row.payload_ref), {
        TTL: remainingTtl,
        urgency: 'normal',
        messageId: row.message_id,
        replayKey: row.replay_key,
      });
      await db.close(row.dlq_id, 'replayed', null);
      outcome.replayed += 1;
      consecutiveFailures = 0;
    } catch (err) {
      await db.release(row.dlq_id, err);   // back to 'parked', attempt_count += 1
      outcome.failed += 1;
      if (++consecutiveFailures >= CIRCUIT_BREAK_AFTER) {
        throw new Error(`Replay aborted after ${CIRCUIT_BREAK_AFTER} consecutive failures`);
      }
    }
    await pace(REPLAY_RATE);   // never let a drain starve live traffic
  }
  return outcome;
}

Gate 4 is the subtle one. Re-sending with the original TTL value hands the push service a fresh full-length storage window for a message that is already old — the message can then sit in push-service storage for another full TTL and arrive absurdly late, which is exactly the pathology described in why push messages arrive hours late. Send the remaining TTL and the arithmetic stays honest.

Replay pipeline: four gates before re-dispatch A dead-letter record passes through a TTL gate checking expires_at is in the future, an endpoint gate checking the subscription is still active, and an idempotency gate checking the replay key is unseen, before reaching re-dispatch. Each gate has a downward reject branch: discard as stale, delete the subscription, and skip as already sent. A footer notes that replay is rate-limited and that every replay writes a new dispatch row rather than mutating the record. Replay pipeline: four gates before a re-dispatch A replay that skips any gate resurrects a message the user should never see. DLQ record state = parked TTL gate expires_at > now Endpoint gate still subscribed Idempotency replay_key unseen Re-dispatch remaining TTL only no no no Discard: stale close as discarded Subscription gone do not resurrect Already replayed skip, no duplicate Drains run at 10% of live send capacity and abort after five consecutive failures. Every replay writes a new dispatch row; the DLQ record is closed, never edited in place.
The gates run in this order deliberately: the cheapest check that can reject a message runs first, and nothing writes to the push service until all four pass.

Alerting Thresholds

A DLQ nobody watches is a slow data-loss mechanism. Alert on rate and composition, not on absolute depth — depth is meaningless without a baseline.

Signal Warning Page Why it matters
DLQ arrival rate > 0.5% of sends over 15 min > 2% over 5 min A payload or credential regression is live
Single error_code share > 60% of arrivals in 1 h > 90% in 15 min One systematic bug, not background noise
Single endpoint_host share > 70% of arrivals in 1 h One push service is degraded; consider failover
Oldest parked record age > 12 h > 80% of median TTL Records are about to become unreplayable
Records discarded as ttl_expired > 100/day > 5% of parked volume Retry budget is too long for the TTL in use
Replay failure rate > 10% of a drain > 30% of a drain The underlying fault was never fixed

Pair the age alert with the TTL policy from the TTL and expiration handling guide: if your median message TTL is 3,600 seconds, a record parked for 12 hours is already unreplayable and the alert should fire long before an operator finds it.

Gotchas and Edge Cases

  • A broker DLQ is not an application DLQ. RabbitMQ’s x-dead-letter-exchange and SQS’s redrive policy capture broker-level failure (nack, visibility timeout, max receives). They know nothing about HTTP status classes and will happily dead-letter a 410. Use the broker DLQ for worker crashes and your own table for delivery outcomes.
  • Redrive-all is not a replay. Moving every message back to the source queue re-runs the failure that put them there, at full rate, usually during the same incident. Always drain through the gates, always rate-limited, always with a circuit breaker.
  • replay_state = 'claimed' needs a lease, not a flag. A worker that dies mid-drain leaves records claimed forever. Store claimed_until and reclaim expired leases, or the DLQ silently shrinks to zero replayable rows.
  • Encrypt at rest and set a retention policy. DLQ records reference user subscriptions and often notification context. Ninety days is a common ceiling; expired records should be deleted by the same job that handles pruning expired subscriptions.
  • Never let the DLQ writer share a transaction with the sender. If parking a record requires the same database transaction as the dispatch update, a database incident that causes send failures also prevents you from recording them. Write DLQ entries through a separate connection with its own timeout, and drop to a local log if that write fails.

FAQ

Should a 410 Gone response ever be dead-lettered?

No. A 410 Gone is a definitive statement that the subscription no longer exists, and the only correct action is to delete the subscription row and stop sending to it. There is nothing to replay and nothing to triage. Dead-lettering 410s produces a queue that grows with normal churn — a few percent of your subscriber base every month — and buries the failures that do need attention.

How long should a message stay in the DLQ before it is discarded?

Freshness and retention are two different clocks. A record becomes unreplayable at expires_at, which is the original dispatch time plus the original TTL — often within hours. It should be retained far longer, typically 30 to 90 days, because its value after expiry is diagnostic rather than operational. Close it as discarded at expires_at, delete it at the end of the retention window.

What stops a replay from sending the same notification twice?

A unique replay_key per DLQ record, enforced by a unique constraint on the dispatch table. The replay worker claims the key before it sends; if the claim fails, another worker already replayed this record and the current one closes it as a duplicate. Relying on replay_state alone is not sufficient — two workers can read parked at the same instant unless the claim is a conditional update.

Back to Retry Logic & Backoff Strategies