DEV Community

Said Olano
Said Olano

Posted on

Dead Letter Pattern: How to Handle Failed Messages Without Losing Data

Dead Letter Pattern: How to Handle Failed Messages Without Losing Data

Every message-driven system will eventually face the same problem: what happens when a message fails to process?

At a fintech company, I watched a critical issue unfold in real time. We were processing payment confirmations from a partner bank. One payment—a $50,000 wire transfer—arrived in a format we didn't expect. Our message processor threw an exception, the message was rejected, and we lost track of it.

We never confirmed the payment to the customer. The customer never received their funds. It took us 6 hours to trace what happened and manually recover the transaction.

That's when we implemented the Dead Letter Pattern.

What Is the Dead Letter Pattern?

The Dead Letter Pattern is a way to handle messages that cannot be processed successfully. Instead of losing the message or crashing the system, you route it to a separate queue or topic (the "dead letter queue" or DLQ) where it can be investigated, debugged, and potentially reprocessed.

Think of it like mail at the Post Office. If a piece of mail can't be delivered to the intended recipient (wrong address, person moved, etc.), it doesn't get thrown away. It goes to the "Dead Letter" office where someone investigates it and decides what to do next.

The Problem Without Dead Letter Pattern

Without a DLQ, you have three options:

  1. Fail silently: The message is dropped. You have no idea what happened.
  2. Crash the consumer: Throw an exception and stop processing. All subsequent messages are blocked.
  3. Retry forever: Keep trying to process the same message forever, wasting resources.

All three options are bad.

How the Dead Letter Pattern Works

Here's the flow:

Message arrives
    ↓
Try to process
    ↓
Success?
    ├─ YES → Process complete, remove from queue
    │
    └─ NO → Retry logic
         ↓
         Retry count exceeded?
         ├─ NO → Put back in queue, wait and retry
         │
         └─ YES → Send to Dead Letter Queue
              ↓
              Alert operations team
              Manual investigation & recovery
Enter fullscreen mode Exit fullscreen mode

The Key Principle

The Dead Letter Pattern is simple: Never lose data. Always have a recovery path.

Real-World Example: Payment Processing Pipeline

Let me show you a complete implementation from fintech. This is how we process payment confirmations from multiple banks, with proper error handling and dead letter routing.

@Configuration
@EnableKafka
public class PaymentConfirmationConsumer {

  public static final String PAYMENT_TOPIC = "payment-confirmations";
  public static final String DLQ_TOPIC = "payment-confirmations-dlq";
  public static final int MAX_RETRY_ATTEMPTS = 3;
  public static final long RETRY_DELAY_MS = 5000;

  private static final Logger log = LoggerFactory.getLogger(PaymentConfirmationConsumer.class);

  @Autowired
  private PaymentService paymentService;

  @Autowired
  private KafkaTemplate<String, PaymentConfirmation> kafkaTemplate;

  @Autowired
  private DeadLetterRepository deadLetterRepository;

  /**
   * Main consumer: Process payment confirmations from banks
   */
  @KafkaListener(
    topics = PAYMENT_TOPIC,
    groupId = "payment-confirmation-group",
    concurrency = "10"
  )
  public void processPaymentConfirmation(
    @Payload PaymentConfirmation confirmation,
    @Header(name = KafkaHeaders.RECEIVED_TOPIC) String topic,
    @Header(name = KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
    @Header(name = KafkaHeaders.OFFSET) long offset,
    Acknowledgment ack
  ) {
    try {
      log.info("Processing payment confirmation: {}", confirmation.getId());

      // Validate the confirmation format
      validatePaymentConfirmation(confirmation);

      // Process the payment in our system
      paymentService.confirmPayment(confirmation);

      // Acknowledge successful processing
      ack.acknowledge();
      log.info("Successfully processed payment: {}", confirmation.getId());

    } catch (ValidationException e) {
      // Validation failure = DLQ immediately
      log.error("Validation failed for payment {}: {}", confirmation.getId(), e.getMessage());
      sendToDeadLetterQueue(confirmation, "VALIDATION_ERROR", e.getMessage(), null);
      ack.acknowledge();

    } catch (RetryableException e) {
      // Transient error = Retry with exponential backoff
      log.warn("Retryable error for payment {}: {}", confirmation.getId(), e.getMessage());
      handleRetryableError(confirmation, e);
      // Don't acknowledge - message will be redelivered

    } catch (Exception e) {
      // Unexpected error = Log and route to DLQ
      log.error("Unexpected error processing payment {}", confirmation.getId(), e);
      sendToDeadLetterQueue(confirmation, "PROCESSING_ERROR", e.getMessage(), e);
      ack.acknowledge();
    }
  }

  /**
   * Validate payment confirmation format
   */
  private void validatePaymentConfirmation(PaymentConfirmation confirmation) 
    throws ValidationException {

    if (confirmation.getId() == null || confirmation.getId().isEmpty()) {
      throw new ValidationException("Payment ID is required");
    }
    if (confirmation.getAmount() == null || confirmation.getAmount().signum() <= 0) {
      throw new ValidationException("Amount must be positive");
    }
    if (confirmation.getTimestamp() == null) {
      throw new ValidationException("Timestamp is required");
    }
  }

  /**
   * Handle retryable errors with exponential backoff
   */
  private void handleRetryableError(PaymentConfirmation confirmation, RetryableException e) {
    int retryCount = confirmation.getRetryCount() + 1;

    if (retryCount >= MAX_RETRY_ATTEMPTS) {
      // Max retries exceeded - send to DLQ
      log.error("Max retries exceeded for payment {}", confirmation.getId());
      sendToDeadLetterQueue(
        confirmation, 
        "MAX_RETRIES_EXCEEDED", 
        e.getMessage(), 
        e
      );
      return;
    }

    // Calculate exponential backoff
    long delayMs = RETRY_DELAY_MS * (long) Math.pow(2, retryCount - 1);

    // Schedule retry with delay
    confirmation.setRetryCount(retryCount);
    kafkaTemplate.send(PAYMENT_TOPIC, confirmation.getId(), confirmation);

    log.info("Scheduled retry {} for payment {} (delay: {} ms)", 
      retryCount, confirmation.getId(), delayMs);
  }

  /**
   * Send message to Dead Letter Queue with metadata
   */
  private void sendToDeadLetterQueue(
    PaymentConfirmation confirmation,
    String errorType,
    String errorMessage,
    Exception exception
  ) {
    try {
      // Create DLQ record with metadata
      DeadLetterRecord dlqRecord = DeadLetterRecord.builder()
        .originalMessage(confirmation)
        .errorType(errorType)
        .errorMessage(errorMessage)
        .exceptionStackTrace(getStackTrace(exception))
        .receivedAt(LocalDateTime.now())
        .status("PENDING_INVESTIGATION")
        .build();

      // Save to database for investigation
      deadLetterRepository.save(dlqRecord);

      // Send to Kafka DLQ topic for real-time monitoring
      kafkaTemplate.send(DLQ_TOPIC, confirmation.getId(), dlqRecord);

      // Alert operations team (e.g., via Slack, PagerDuty, email)
      alertOperationsTeam(dlqRecord);

      log.error("Sent to DLQ - Payment: {}, Error: {}", confirmation.getId(), errorType);

    } catch (Exception dlqException) {
      // DLQ send failed - this is critical!
      log.error("CRITICAL: Failed to send to DLQ for payment {}", 
        confirmation.getId(), dlqException);
      // At this point, send alert to on-call engineer and escalate
    }
  }

  /**
   * Listener for Dead Letter Queue messages
   * These are typically monitored by operations team for investigation
   */
  @KafkaListener(
    topics = DLQ_TOPIC,
    groupId = "dlq-monitoring-group"
  )
  public void monitorDeadLetterQueue(@Payload DeadLetterRecord dlqRecord) {
    log.error("DLQ Message - Payment: {}, Error: {}, Message: {}",
      dlqRecord.getOriginalMessage().getId(),
      dlqRecord.getErrorType(),
      dlqRecord.getErrorMessage()
    );
    // Log to monitoring system (Datadog, New Relic, etc)
  }

  private String getStackTrace(Exception exception) {
    if (exception == null) return "";
    StringWriter sw = new StringWriter();
    exception.printStackTrace(new PrintWriter(sw));
    return sw.toString();
  }

  private void alertOperationsTeam(DeadLetterRecord dlqRecord) {
    // Send alert via Slack, email, or monitoring system
    // Example: notify @oncall in #payments-dlq channel
  }
}

/**
 * Controller for manual recovery of DLQ messages
 */
@RestController
@RequestMapping("/api/dlq")
public class DeadLetterController {

  @Autowired
  private DeadLetterRepository deadLetterRepository;

  @Autowired
  private KafkaTemplate<String, PaymentConfirmation> kafkaTemplate;

  /**
   * Get all pending DLQ messages
   */
  @GetMapping("/pending")
  public List<DeadLetterRecord> getPendingMessages() {
    return deadLetterRepository.findByStatus("PENDING_INVESTIGATION");
  }

  /**
   * Retry a DLQ message (after manual investigation/fix)
   */
  @PostMapping("/{id}/retry")
  public ResponseEntity<?> retryMessage(@PathVariable String id) {
    DeadLetterRecord record = deadLetterRepository.findById(id)
      .orElseThrow(() -> new NotFoundException("DLQ record not found"));

    // Send back to main topic for reprocessing
    kafkaTemplate.send(
      "payment-confirmations", 
      record.getOriginalMessage().getId(), 
      record.getOriginalMessage()
    );

    // Mark as retried
    record.setStatus("RETRIED");
    record.setRetriedAt(LocalDateTime.now());
    deadLetterRepository.save(record);

    return ResponseEntity.ok("Message requeued for processing");
  }

  /**
   * Mark a message as unrecoverable
   */
  @PostMapping("/{id}/discard")
  public ResponseEntity<?> discardMessage(@PathVariable String id) {
    DeadLetterRecord record = deadLetterRepository.findById(id)
      .orElseThrow(() -> new NotFoundException("DLQ record not found"));

    record.setStatus("DISCARDED");
    record.setDiscardedAt(LocalDateTime.now());
    deadLetterRepository.save(record);

    // Send alert to compliance/audit team
    // (We need to audit why a payment was discarded)

    return ResponseEntity.ok("Message marked as discarded");
  }
}

/**
 * Custom exceptions for retry logic
 */
public class RetryableException extends Exception {
  public RetryableException(String message) {
    super(message);
  }
}

public class ValidationException extends Exception {
  public ValidationException(String message) {
    super(message);
  }
}

/**
 * Entity for storing DLQ records
 */
@Entity
@Table(name = "dead_letter_records")
public class DeadLetterRecord {
  @Id
  private String id;

  @Lob
  private String originalMessage;

  private String errorType;

  @Lob
  private String errorMessage;

  @Lob
  private String exceptionStackTrace;

  private LocalDateTime receivedAt;
  private LocalDateTime retriedAt;
  private LocalDateTime discardedAt;

  private String status; // PENDING_INVESTIGATION, RETRIED, DISCARDED

  // Getters, setters, builders...
}
Enter fullscreen mode Exit fullscreen mode

What's Happening Here

Main Consumer:

  • Listens to payment confirmations
  • Validates format (throws ValidationException → immediate DLQ)
  • Processes payment (throws RetryableException → retry with backoff)
  • Catches unexpected errors → DLQ

Retry Logic:

  • Tries 3 times with exponential backoff (5s, 10s, 20s)
  • After max retries, sends to DLQ

Dead Letter Queue:

  • Stores record in database (for audit trail)
  • Sends to Kafka topic (for monitoring)
  • Alerts operations team
  • If DLQ send fails → CRITICAL ALERT

Recovery API:

  • /api/dlq/pending - See what's broken
  • /api/dlq/{id}/retry - Fix and reprocess
  • /api/dlq/{id}/discard - Audit trail for unrecoverable messages

Why This Matters

1. Never Lose Data

Every message is tracked. Even if processing fails 10 times, we know it exists and can recover it.

2. Operational Visibility

You know exactly what's broken. No "mysterious missing payments" like I had in that $50K situation.

3. Graceful Degradation

Your system keeps running even when individual messages fail. The main pipeline doesn't crash.

4. Audit Trail

In fintech, you need proof of what happened with each message. DLQ provides that.

5. Recovery Without Code Deployment

Operations team can fix DLQ messages via API, no code changes needed.

Common Mistakes

Mistake 1: Logging to DLQ but Never Reading It

Don't send messages to DLQ and then forget about them. Set up monitoring. Alert the team when messages arrive.

Mistake 2: Losing the DLQ Message Too

If your DLQ fails (database down, Kafka down), you're back to losing data. Have redundancy.

Mistake 3: Treating All Errors the Same

Validation errors (invalid JSON, missing fields) → immediate DLQ (no retry)
Transient errors (network timeout, DB temporarily down) → retry with backoff
Unknown errors → investigate before retrying

Mistake 4: No Max Retry Count

Retrying forever wastes resources and masks problems. Set a limit (3-5 retries is typical).

When to Use Dead Letter Pattern

✅ Use when:

  • Messages must not be lost (financial transactions, orders, critical events)
  • Processing can fail and needs recovery
  • You need audit trails

❌ Don't use when:

  • Data is truly disposable
  • You don't care about individual messages
  • Processing must be real-time (DLQ adds latency)

Conclusion

The Dead Letter Pattern saved us from losing millions in transactions. It's not optional for systems that handle important data—it's essential infrastructure.

The investment is small (a queue, a database table, an API endpoint), but the peace of mind is enormous.


Have you lost messages in production? What was your recovery process? Share your stories—I'd love to hear how you've handled failures in your systems.

If you found this valuable, follow for more on building resilient systems, event-driven architecture, and the patterns that save your butt at 3am.

Top comments (0)