81

A poison pill doesn’t stop the partition; a DLQ is an obligation, not a trash can

#
  • pattern
  • context-dependent
  • fragment

Conflicts with: DLQ ↔ per-key ordering

With a naive consumer, one unparsable message in a partition stops the whole partition forever (a crash loop on a single offset). The policy, by construction: N attempts → dead letter with full context (original message, error, offset, stack trace, schema version) → the partition moves on. But a DLQ is a queue of unprocessed obligations: it has an owner, an alert on growth and a re-drive procedure; a DLQ without a consumer is /dev/null with a clear conscience.

Code example (fragment)
Gofragment
if err := process(msg); err != nil {
    if !errors.Is(err, ErrTransient) || attempts(msg) >= 3 {
        dlq.Send(ctx, DeadLetter{
            Original: msg.Raw, Topic: msg.Topic, Offset: msg.Offset,
            Error: err.Error(), SchemaVer: msg.Header("schema"), FirstSeen: now,
        })
        consumer.Commit(msg) // partition unblocked: that was the whole point
        return nil
    }
    return err // transient → retry with backoff (24), offset stays put
}

Honest assessment. A DLQ breaks per-key ordering: if message N went to the DLQ while N+1 with the same key was processed, re-drive will apply N after N+1. For ordering-sensitive keys (balances, statuses) the correct alternative is to park the whole key (pause the key, not the partition) or to re-drive via a full replay of the key (80). The choice follows the invariant, and it must be written down.

“Pausing a key” is not a built-in Kafka operation but a separate architecture: buffering or routing by key, pause() on partitions, or retry topics that restore ordering. Plus the details without which a DLQ does harm: an attempt counter kept outside process memory, PII redaction in the payload, schema and version metadata, idempotent re-drive, quarantine for poison messages, and stack-trace truncation.

Last technical review: 2026-08-04 · Reference · 112 principles