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:
- Fail silently: The message is dropped. You have no idea what happened.
- Crash the consumer: Throw an exception and stop processing. All subsequent messages are blocked.
- 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
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...
}
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)