Siddhant Deval
Siddhant Deval
backend15 min read

Observability, Dead-Letter Queues, and Production Incident Patterns

An unmonitored dead-letter queue is a silent data loss mechanism — not a safety net. This article designs production-grade DLQ pipelines with exponential backoff, poison-message quarantine, and OpenTelemetry span propagation via message headers. It then catalogs the four most common messaging production incidents and gives a root cause, observable symptom, and remediation for each.

Observability, Dead-Letter Queues, and Production Incident Patterns

The on-call engineer is looking at a Kafka consumer group that has been at zero lag for two hours. The consumer is processing successfully. Orders are being fulfilled. Then a finance analyst asks why 47 payments are missing from this morning's revenue report. The payments were processed — the payment.processed events were published — but the analytics consumer silently sent them to its dead-letter queue at 2 a.m. when a schema mismatch caused a deserialization error. The DLQ has been accumulating messages for six hours. Nobody was alerted.

A DLQ with no alerting is not a safety net. It is a bucket that silently fills with missed business outcomes.

Architectural Note

Series positioning: This is Part 10 of Distributed Messaging Systems. It closes the production operations loop started with delivery guarantees (Part 5) and consumer patterns (Part 6). The prerequisite for the OTel section is OpenTelemetry: Distributed Tracing, Structured Logging, and Observability.


1. The Four Metrics Every Messaging System Must Export

Before DLQ pipelines and tracing, establish these four fundamental metric signals:

TYPESCRIPT
// ✅ Core messaging metrics — export these from every consumer
import { metrics } from '@opentelemetry/api'

const meter = metrics.getMeter('messaging-consumer', '1.0.0')

const consumerLag        = meter.createObservableGauge('messaging.consumer.lag')
const dlqDepth           = meter.createObservableGauge('messaging.dlq.depth')
const processingDuration = meter.createHistogram('messaging.message.processing.duration.ms')
const errorRate          = meter.createCounter('messaging.message.errors.total')

// Observe lag per partition per consumer group
consumerLag.addCallback(async (result) => {
  const offsets = await admin.fetchOffsets({ groupId: 'fulfillment-service', topics: ['orders.created'] })
  for (const topic of offsets) {
    for (const partition of topic.partitions) {
      const end = await admin.fetchTopicOffsetsByTimestamp(topic.topic, Date.now())
      const lag = Number(end[partition.partition].offset) - Number(partition.offset)
      result.observe(lag, {
        'messaging.consumer.group': 'fulfillment-service',
        'messaging.topic':          topic.topic,
        'messaging.partition':      partition.partition.toString(),
      })
    }
  }
})

// Observe DLQ depth — alert when non-zero
dlqDepth.addCallback(async (result) => {
  const dlqOffset = await admin.fetchTopicOffsets('orders.dlq')
  for (const partition of dlqOffset) {
    result.observe(Number(partition.offset), {
      'messaging.dlq.topic': 'orders.dlq',
      'messaging.partition': partition.partition.toString(),
    })
  }
})
Metric Alert threshold Severity
messaging.consumer.lag > 10,000 for > 5 min P2 — consumer falling behind
messaging.dlq.depth > 0 for > 1 min P0 — active data loss
messaging.message.processing.duration.ms p99 > 2× baseline P3 — performance degradation
messaging.message.errors.total rate > 1% of throughput P2 — systemic processing failure
Performance / Safety Warning

Alert on DLQ depth at threshold > 0 for more than 60 seconds — not at some large number. A single poison message that reaches the DLQ represents a business event that will not be processed. Treat every DLQ message as a P0 until proven otherwise.


2. Trace ID Propagation Across Async Message Boundaries

2.1 The Problem: Traces Break at Async Boundaries

In synchronous HTTP systems, OpenTelemetry trace context flows automatically via HTTP headers. In async messaging, the trace context must be manually serialized into the message by the producer and manually extracted and restored by the consumer.

TYPESCRIPT
// ❌ No trace propagation — producer and consumer are invisible to each other
await producer.send({
  topic:    'orders.created',
  messages: [{ key: order.id, value: JSON.stringify(order) }]
  // No trace context — consumer's spans appear as disconnected roots in Jaeger/Grafana
})

2.2 W3C TraceContext in Message Headers

The W3C traceparent and tracestate headers are the standard — use them in Kafka message headers:

TYPESCRIPT
// ✅ Producer: inject trace context into message headers
import { context, propagation, trace } from '@opentelemetry/api'

const tracer = trace.getTracer('order-service')

async function publishOrderCreated(order: Order): Promise<void> {
  const span = tracer.startSpan('messaging.produce', {
    kind: SpanKind.PRODUCER,
    attributes: {
      'messaging.system':          'kafka',
      'messaging.destination':     'orders.created',
      'messaging.destination.kind':'topic',
      'messaging.message.id':       order.id,
    }
  })

  await context.with(trace.setSpan(context.active(), span), async () => {
    // Inject current trace context into headers
    const headers: Record<string, string> = {}
    propagation.inject(context.active(), headers)
    // headers now contains: { traceparent: '00-abc...', tracestate: '...' }

    await producer.send({
      topic:    'orders.created',
      messages: [{
        key:     order.id,
        value:   JSON.stringify(order),
        headers: {
          traceparent:         headers['traceparent'],
          tracestate:          headers['tracestate'] ?? '',
          'x-correlation-id':  order.id,          // business-level ID for log correlation
          'x-causation-id':    span.spanContext().spanId,
        }
      }]
    })
    span.end()
  })
}
TYPESCRIPT
// ✅ Consumer: extract trace context and link to parent span
await consumer.run({
  eachMessage: async ({ message, topic, partition }) => {
    // Extract trace context from headers
    const extractedContext = propagation.extract(context.active(), {
      traceparent: message.headers?.['traceparent']?.toString(),
      tracestate:  message.headers?.['tracestate']?.toString(),
    })

    const span = tracer.startSpan('messaging.consume', {
      kind: SpanKind.CONSUMER,
      attributes: {
        'messaging.system':            'kafka',
        'messaging.source':            topic,
        'messaging.kafka.partition':   partition,
        'messaging.kafka.offset':      message.offset,
        'messaging.message.id':        message.headers?.['x-correlation-id']?.toString(),
      }
    }, extractedContext)   // links to the producer's span via parent context

    const start = Date.now()
    try {
      await context.with(trace.setSpan(extractedContext, span), async () => {
        await processOrder(JSON.parse(message.value!.toString()))
      })
      processingDuration.record(Date.now() - start, { status: 'success' })
    } catch (err) {
      span.recordException(err as Error)
      span.setStatus({ code: SpanStatusCode.ERROR })
      errorRate.add(1, { 'error.type': (err as Error).constructor.name })
      processingDuration.record(Date.now() - start, { status: 'error' })
      throw err
    } finally {
      span.end()
    }
  }
})
Crucial Requirement

Set x-correlation-id as a business-level identifier (order ID, payment ID) in addition to traceparent. traceparent is for tracing systems; x-correlation-id is for log search. When investigating an incident, you will search your logs by order ID — not by a trace ID you don't know ahead of time.


3. DLQ Pipeline Design

3.1 The Three-Stage DLQ Architecture

3.2 Kafka DLQ Implementation

TYPESCRIPT
// ✅ Kafka: DLQ with retry counter and dead-lettering
const MAX_RETRIES = 5

async function processWithDLQ(
  message: KafkaMessage,
  topic:   string,
  partition: number,
): Promise<void> {
  const retryCount = Number(message.headers?.['x-retry-count'] ?? 0)

  try {
    await processOrder(JSON.parse(message.value!.toString()))
  } catch (err) {
    const isRetryable = !(err instanceof NonRetryableError)

    if (isRetryable && retryCount < MAX_RETRIES) {
      // Exponential backoff: re-publish with incremented counter
      const delayMs = Math.min(1000 * 2 ** retryCount, 60_000)  // cap at 60s
      await new Promise(resolve => setTimeout(resolve, delayMs))

      await producer.send({
        topic:    topic,
        messages: [{
          key:     message.key,
          value:   message.value,
          headers: {
            ...message.headers,
            'x-retry-count':    (retryCount + 1).toString(),
            'x-original-topic': topic,
            'x-error-message':  (err as Error).message.slice(0, 256),
            traceparent:        message.headers?.['traceparent'],
          }
        }]
      })
    } else {
      // Max retries exceeded or non-retryable — send to DLQ
      await producer.send({
        topic:    `${topic}.dlq`,
        messages: [{
          key:     message.key,
          value:   message.value,
          headers: {
            ...message.headers,
            'x-dlq-reason':       retryCount >= MAX_RETRIES ? 'max-retries-exceeded' : 'non-retryable',
            'x-dlq-at':           new Date().toISOString(),
            'x-error-message':    (err as Error).message.slice(0, 256),
            'x-retry-count':      retryCount.toString(),
            'x-original-topic':   topic,
            'x-original-partition': partition.toString(),
            traceparent:          message.headers?.['traceparent'],  // preserve trace linkage
          }
        }]
      })
      // Increment the DLQ depth metric
      errorRate.add(1, { 'error.type': 'dlq', 'dlq.reason': 'max-retries-exceeded' })
    }
  }
}

3.3 Poison Message Quarantine

A poison message is one that causes the consumer to crash deterministically — schema error, deserialization failure, or null pointer in fixed business logic. It must be quarantined, not retried:

TYPESCRIPT
// ✅ Poison message quarantine — detect non-retryable errors, skip and isolate
class NonRetryableError extends Error {
  constructor(message: string, public readonly reason: string) {
    super(message)
    this.name = 'NonRetryableError'
  }
}

async function deserializeMessage(rawValue: Buffer): Promise<OrderEvent> {
  try {
    return await registry.decode(rawValue)
  } catch (err) {
    // Schema mismatch, corrupt payload — no amount of retrying will fix this
    throw new NonRetryableError(
      `Deserialization failed: ${(err as Error).message}`,
      'schema-mismatch'
    )
  }
}

// Consumer: detect and quarantine immediately
await consumer.run({
  eachMessage: async ({ message, topic, partition }) => {
    try {
      const event = await deserializeMessage(message.value!)
      await processOrder(event)
    } catch (err) {
      if (err instanceof NonRetryableError) {
        // Skip retry loop — goes directly to DLQ + quarantine
        await quarantineMessage(message, topic, partition, err.reason)
        return   // ack the message — do NOT rethrow (would trigger infinite retry)
      }
      throw err   // retryable — propagate to retry logic
    }
  }
})
Performance / Safety Warning

Never rethrow a NonRetryableError in a Kafka consumer. If the consumer throws on a poison message without sending it to the DLQ, Kafka will not advance the offset — it will redeliver the same message on every poll indefinitely, pinning the partition thread and blocking all subsequent messages in that partition. Quarantine the message, ack it (by committing the offset), and move on.


4. The Four Production Incident Playbooks

4.1 Consumer Lag Spike — Downstream Saturation

Observable: Lag grows linearly on all partitions simultaneously. Consumer error rate near zero. DB write latency climbing.

Root cause: Downstream database under write pressure — every consumer thread blocks waiting for DB ack.

BASH
# Diagnose: check consumer lag trend + DB write latency correlation
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group fulfillment-service

# Check DB write latency (PostgreSQL)
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE query ILIKE '%INSERT%' OR query ILIKE '%UPDATE%'
ORDER BY mean_exec_time DESC LIMIT 10;

Remediation: Reduce max.poll.records (immediate backpressure), identify and fix the DB bottleneck (add index, fix missing vacuum, batch writes).

4.2 Rebalance Storm — max.poll.interval.ms Exceeded

Observable: Consumer group logs show continuous LeaveGroup / JoinGroup / SyncGroup cycles. Lag oscillates (drops, then spikes). Processing appears to happen but lag never reaches zero.

Root cause: Processing time per batch exceeds max.poll.interval.ms — the broker declares the consumer dead mid-batch, rebalances, then the consumer rejoins and re-processes from the last committed offset.

TYPESCRIPT
// Fix: increase max.poll.interval.ms to match actual processing time
const consumer = kafka.consumer({
  groupId:             'fulfillment-service',
  sessionTimeout:      60_000,   // 60s
  heartbeatInterval:   10_000,   // 10s (< sessionTimeout / 3)
  // In Java client: maxPollIntervalMs: 300_000 (5 min)
  // In kafkajs: handled by eachBatch heartbeat() calls
})

4.3 DLQ Accumulation — Poison Message Storm

Observable: DLQ depth rising rapidly. Consumer lag flat (messages being processed), but DLQ depth alert fires. Application error rate high.

Root cause: A schema change or data quality issue causes a class of messages to fail deserialization consistently.

Remediation:

  1. Pause the consumer group immediately to stop further DLQ accumulation.
  2. Inspect the DLQ messages — identify the schema version (schema_id in Avro header).
  3. Identify the producer deploy that introduced the change.
  4. Rollback the producer if the schema change was not backward-compatible.
  5. Fix the consumer, redeploy, replay DLQ messages.
BASH
# Pause a consumer group by setting all offsets to current position
# (consumers will not advance — new messages accumulate but are not processed)
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group fulfillment-service --reset-offsets --to-current \
  --topic orders.created --execute

4.4 Offset Out of Range — Consumer Offline Longer Than Retention

Observable: Consumer restarts throw OffsetOutOfRangeError. Consumer group committed offset is behind the earliest available offset in the log.

Root cause: Consumer was offline (maintenance, bug, forgotten scaling-to-zero) for longer than the topic's retention period. The log has rotated past the last committed offset.

TYPESCRIPT
// ✅ Handle OffsetOutOfRangeError gracefully
consumer.on(consumer.events.FETCH_START, async () => {
  // kafkajs emits fetch errors as events — handle gracefully
})

// Configure: on out-of-range, jump to earliest available offset (accept data gap)
const consumer = kafka.consumer({
  groupId:             'analytics-service',
  // On OffsetOutOfRangeError: reset to 'earliest' available offset
  // This means: accept that data was lost during the offline window
  // Alternative: reset to 'latest' — start from now, larger data gap
})

// Emit an alert: offset reset = data gap = must audit downstream read models
errorRate.add(1, { 'error.type': 'offset-out-of-range', 'action': 'reset-to-earliest' })

Summary

Concept Rule
DLQ as P0 alert A DLQ with no alerting is equivalent to silent message loss — instrument DLQ depth as a P0 alert threshold.
Trace ID propagation Correlation IDs must be set by the first producer in the chain and propagated by every consumer — retrofitting tracing after an incident is too late.
Poison message isolation Poison messages must be isolated, not infinitely retried — a single malformed message can block an entire partition's processing indefinitely without a max-retry + DLQ gate.

What's Next

Part 11: Producer Tuning — Batching, Compression, and Throughput Optimization turns to the other end of the pipeline: how producers control throughput, durability, and message size. linger.ms, batch.size, compression.type, and the idempotent producer configuration are the levers — and getting them wrong costs either throughput or durability.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Observability#Dead Letter Queue#OpenTelemetry#Distributed Tracing#Incident Response#Kafka#RabbitMQ#Backend
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.