Kafka Dead Letter Topics: Handling Failed Messages
A Dead Letter Topic (DLT) is a dedicated Kafka topic where messages that cannot be processed successfully are routed after exhausting all retry attempts. Instead of blocking the consumer or silently dropping records, the DLT captures failed messages along with diagnostic headers so they can be inspected, monitored, and replayed later.
Contents
What Are Dead Letter Topics
In any event-driven system, some messages will inevitably fail to process. A consumer might encounter malformed JSON, a missing required field, a downstream service outage, or a schema mismatch. Without a strategy for handling these failures, the consumer either blocks indefinitely on the bad record or skips it with no trace.
A Dead Letter Topic (DLT) solves this problem by providing a safe destination for messages that have exhausted all retry attempts. The original message is published to the DLT along with metadata headers that capture the failure context: the original topic, partition, offset, exception class, and stack trace.
Common reasons a message ends up in a DLT:
- Poison pill records: messages that can never be deserialized or processed regardless of how many times they are retried (e.g., corrupted bytes, incompatible schema changes).
- Validation failures: the payload is syntactically valid but violates business rules (missing required fields, out-of-range values).
- Transient downstream failures: a database or external API is temporarily unavailable and retries are exhausted.
- Deserialization errors: the consumer cannot convert the raw bytes into the expected object type.
DLT Naming Conventions
Spring Kafka follows a standard naming convention where the DLT name is derived from the original topic
name by appending a .DLTordersorders.DLT
| Original Topic | Dead Letter Topic |
|---|---|
orders | orders.DLT |
payments | payments.DLT |
user-events | user-events.DLT |
When a message is forwarded to the DLT, Spring Kafka attaches several headers to preserve context about the original failure:
: the topic where the message was originally consumed.kafka_dlt-original-topic : the partition number in the source topic.kafka_dlt-original-partition : the offset of the failed record.kafka_dlt-original-offset : the original record timestamp.kafka_dlt-original-timestamp : the fully qualified class name of the exception.kafka_dlt-exception-fqcn : the exception message text.kafka_dlt-exception-message : the full stack trace of the failure.kafka_dlt-exception-stacktrace
Spring Kafka DefaultErrorHandler
The DefaultErrorHandlerSeekToCurrentErrorHandlerDeadLetterPublishingRecoverer
@Configuration
public class KafkaConsumerConfig {
@Bean
public KafkaTemplate<String, String> kafkaTemplate(ProducerFactory<String, String> pf) {
return new KafkaTemplate<>(pf);
}
@Bean
public DeadLetterPublishingRecoverer deadLetterRecoverer(KafkaTemplate<String, String> template) {
// Routes failed records to {originalTopic}.DLT
return new DeadLetterPublishingRecoverer(template,
(record, ex) -> new TopicPartition(record.topic() + ".DLT", record.partition()));
}
@Bean
public DefaultErrorHandler errorHandler(DeadLetterPublishingRecoverer recoverer) {
// Retry 3 times with 1-second back-off, then send to DLT
BackOff backOff = new FixedBackOff(1000L, 3);
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
return handler;
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
ConsumerFactory<String, String> cf, DefaultErrorHandler errorHandler) {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(cf);
factory.setCommonErrorHandler(errorHandler);
return factory;
}
}
With this configuration, when a listener throws an exception, the DefaultErrorHandlerDeadLetterPublishingRecoverer
You can also use an exponential back-off strategy for retries:
@Bean
public DefaultErrorHandler errorHandler(DeadLetterPublishingRecoverer recoverer) {
// Exponential back-off: initial 1s, multiplier 2, max 10s, max retries 5
ExponentialBackOff backOff = new ExponentialBackOff(1000L, 2.0);
backOff.setMaxElapsedTime(30000L); // give up after 30 seconds total
return new DefaultErrorHandler(recoverer, backOff);
}
Retry Topics
Spring Kafka provides a powerful non-blocking retry mechanism through the
@RetryableTopic
The retry chain creates a series of topics: orders-retry-0orders-retry-1orders-retry-2orders-dlt
@Component
public class OrderConsumer {
@RetryableTopic(
attempts = "4",
backoff = @Backoff(delay = 2000, multiplier = 2, maxDelay = 16000),
autoCreateTopics = "true",
topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE,
dltStrategy = DltStrategy.FAIL_ON_ERROR
)
@KafkaListener(topics = "orders", groupId = "order-processor")
public void consume(ConsumerRecord<String, String> record) {
Order order = parseOrder(record.value());
orderService.process(order); // may throw on failure
}
@DltHandler
public void handleDlt(ConsumerRecord<String, String> record) {
log.error("Message exhausted all retries. Topic: {}, Key: {}, Value: {}",
record.topic(), record.key(), record.value());
alertService.notifyDltMessage(record);
}
}
The retry flow with the above configuration proceeds as follows:
| Step | Topic | Delay |
|---|---|---|
| 1st attempt | orders | Immediate |
| 2nd attempt | orders-retry-0 | 2 seconds |
| 3rd attempt | orders-retry-1 | 4 seconds |
| 4th attempt | orders-retry-2 | 8 seconds |
| Dead letter | orders-dlt | N/A |
@RetryableTopicCustom Dead Letter Processing
Beyond the default @DltHandler
@Component
public class DltConsumer {
private final KafkaTemplate<String, String> kafkaTemplate;
private final AlertService alertService;
public DltConsumer(KafkaTemplate<String, String> kafkaTemplate, AlertService alertService) {
this.kafkaTemplate = kafkaTemplate;
this.alertService = alertService;
}
@KafkaListener(topics = "orders.DLT", groupId = "dlt-processor")
public void processDlt(ConsumerRecord<String, String> record) {
// Extract original failure context from headers
String originalTopic = headerValue(record, KafkaHeaders.DLT_ORIGINAL_TOPIC);
String exceptionClass = headerValue(record, KafkaHeaders.DLT_EXCEPTION_FQCN);
String exceptionMessage = headerValue(record, KafkaHeaders.DLT_EXCEPTION_MESSAGE);
log.error("DLT record — original topic: {}, exception: {} - {}",
originalTopic, exceptionClass, exceptionMessage);
// Persist to a dead letter database for manual review
deadLetterRepository.save(DeadLetterRecord.builder()
.originalTopic(originalTopic)
.key(record.key())
.value(record.value())
.exceptionClass(exceptionClass)
.exceptionMessage(exceptionMessage)
.receivedAt(Instant.now())
.build());
// Send alert for critical topics
if ("payments".equals(originalTopic)) {
alertService.sendPagerDutyAlert("Payment DLT message: " + record.key());
}
}
private String headerValue(ConsumerRecord<String, String> record, String headerKey) {
Header header = record.headers().lastHeader(headerKey);
return header != null ? new String(header.value(), StandardCharsets.UTF_8) : "unknown";
}
}
DLT Headers
When Spring Kafka publishes a record to the DLT, it attaches a rich set of headers that capture the full failure context. These headers are essential for diagnosing issues and building automated reprocessing logic.
| Header | Constant | Description |
|---|---|---|
kafka_dlt-original-topic |
KafkaHeaders.DLT_ORIGINAL_TOPIC |
Source topic name where the message was originally consumed |
kafka_dlt-original-partition |
KafkaHeaders.DLT_ORIGINAL_PARTITION |
Partition number in the source topic |
kafka_dlt-original-offset |
KafkaHeaders.DLT_ORIGINAL_OFFSET |
Offset of the failed record in the source partition |
kafka_dlt-original-timestamp |
KafkaHeaders.DLT_ORIGINAL_TIMESTAMP |
Timestamp of the original record |
kafka_dlt-original-timestamp-type |
KafkaHeaders.DLT_ORIGINAL_TIMESTAMP_TYPE |
Timestamp type (CreateTime or LogAppendTime) |
kafka_dlt-exception-fqcn |
KafkaHeaders.DLT_EXCEPTION_FQCN |
Fully qualified class name of the exception |
kafka_dlt-exception-message |
KafkaHeaders.DLT_EXCEPTION_MESSAGE |
The exception message text |
kafka_dlt-exception-stacktrace |
KafkaHeaders.DLT_EXCEPTION_STACKTRACE |
Full stack trace of the causing exception |
// Reading all DLT headers from a record
private void logDltHeaders(ConsumerRecord<String, String> record) {
for (Header header : record.headers()) {
if (header.key().startsWith("kafka_dlt")) {
log.info("DLT Header — {}: {}",
header.key(),
new String(header.value(), StandardCharsets.UTF_8));
}
}
}
Non-Retryable Exceptions
Not all exceptions warrant retries. A deserialization error or a validation failure will produce the same result on every attempt, retrying is wasteful. Spring Kafka lets you classify certain exceptions as non-retryable so that they are sent directly to the DLT without any retry attempts.
@Bean
public DefaultErrorHandler errorHandler(DeadLetterPublishingRecoverer recoverer) {
BackOff backOff = new FixedBackOff(1000L, 3);
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
// These exceptions skip retries and go straight to DLT
handler.addNotRetryableExceptions(
DeserializationException.class,
MessageConversionException.class,
ConversionException.class,
MethodArgumentNotValidException.class,
NullPointerException.class
);
return handler;
}
For @RetryableTopic
@RetryableTopic(
attempts = "4",
backoff = @Backoff(delay = 2000, multiplier = 2),
exclude = {
DeserializationException.class,
IllegalArgumentException.class
}
)
@KafkaListener(topics = "orders", groupId = "order-processor")
public void consume(ConsumerRecord<String, String> record) {
orderService.process(record.value());
}
You can also mark exceptions as non-retryable programmatically from within your listener:
@KafkaListener(topics = "orders", groupId = "order-processor")
public void consume(ConsumerRecord<String, String> record) {
try {
Order order = objectMapper.readValue(record.value(), Order.class);
if (order.getAmount() == null) {
// This will never succeed on retry — send straight to DLT
throw new NotRetryableException("Order amount is null for key: " + record.key());
}
orderService.process(order);
} catch (JsonProcessingException e) {
throw new NotRetryableException("Invalid JSON payload", e);
}
}
NullPointerExceptionMonitoring Dead Letter Topics
A DLT that silently accumulates messages without anyone noticing is as bad as having no DLT at all. Monitoring and alerting on DLT activity is essential for maintaining system health.
- Consumer lag on DLT topics, if a DLT consumer is not keeping up, messages pile up and you lose visibility into failures.
- DLT message rate: a sudden spike in DLT messages often indicates a deployment issue (bad schema, broken downstream service).
- Alert on any DLT message for critical topics: payment or order topics should trigger immediate alerts when a message reaches the DLT.
- Track DLT age: messages sitting in the DLT for days may indicate that no one is reviewing or reprocessing them.
@Component
public class DltMetricsCollector {
private final MeterRegistry meterRegistry;
private final Counter dltMessageCounter;
public DltMetricsCollector(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.dltMessageCounter = Counter.builder("kafka.dlt.messages.received")
.description("Number of messages received in DLT topics")
.register(meterRegistry);
}
@KafkaListener(topicPattern = ".*\\.DLT", groupId = "dlt-monitor")
public void monitorDlt(ConsumerRecord<String, String> record) {
String originalTopic = headerValue(record, KafkaHeaders.DLT_ORIGINAL_TOPIC);
// Increment counter with topic tag for per-topic metrics
dltMessageCounter.increment();
meterRegistry.counter("kafka.dlt.messages.by.topic",
"original_topic", originalTopic).increment();
// Gauge for time-in-DLT tracking
meterRegistry.timer("kafka.dlt.message.age",
"original_topic", originalTopic)
.record(Duration.between(
Instant.ofEpochMilli(record.timestamp()), Instant.now()));
}
private String headerValue(ConsumerRecord<String, String> record, String key) {
Header header = record.headers().lastHeader(key);
return header != null ? new String(header.value(), StandardCharsets.UTF_8) : "unknown";
}
}
.*\.DLTReprocessing Strategy
Once a root cause is fixed: a bug is patched, a downstream service is restored, or a schema is corrected: you need a strategy for replaying DLT messages back to the original topic.
The simplest approach is a dedicated reprocessing service that reads from the DLT, optionally transforms the message, and publishes it back to the source topic.
@Service
public class DltReprocessingService {
private final KafkaTemplate<String, String> kafkaTemplate;
public DltReprocessingService(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
/**
* Replay a single DLT record back to its original topic.
* Call this from an admin API endpoint or a scheduled job.
*/
public void replayRecord(ConsumerRecord<String, String> dltRecord) {
String originalTopic = headerValue(dltRecord, KafkaHeaders.DLT_ORIGINAL_TOPIC);
if (originalTopic == null) {
log.error("Cannot replay — missing original topic header for key: {}", dltRecord.key());
return;
}
// Build a new ProducerRecord with the original key and value
ProducerRecord<String, String> replayRecord =
new ProducerRecord<>(originalTopic, dltRecord.key(), dltRecord.value());
// Add a header to track that this is a replayed message
replayRecord.headers().add("x-replay-source", "dlt-reprocessor".getBytes(StandardCharsets.UTF_8));
replayRecord.headers().add("x-replay-timestamp",
Instant.now().toString().getBytes(StandardCharsets.UTF_8));
kafkaTemplate.send(replayRecord).whenComplete((result, ex) -> {
if (ex != null) {
log.error("Failed to replay DLT record to {}: {}", originalTopic, ex.getMessage());
} else {
log.info("Replayed DLT record to {} partition {} offset {}",
result.getRecordMetadata().topic(),
result.getRecordMetadata().partition(),
result.getRecordMetadata().offset());
}
});
}
private String headerValue(ConsumerRecord<String, String> record, String key) {
Header header = record.headers().lastHeader(key);
return header != null ? new String(header.value(), StandardCharsets.UTF_8) : null;
}
}
Expose a REST endpoint to trigger reprocessing on demand:
@RestController
@RequestMapping("/api/dlt")
public class DltReprocessController {
private final Consumer<String, String> consumer;
private final DltReprocessingService reprocessingService;
@PostMapping("/replay/{topic}")
public ResponseEntity<String> replayDlt(
@PathVariable String topic,
@RequestParam(defaultValue = "100") int maxRecords) {
String dltTopic = topic + ".DLT";
TopicPartition tp = new TopicPartition(dltTopic, 0);
consumer.assign(List.of(tp));
consumer.seekToBeginning(List.of(tp));
int replayed = 0;
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));
for (ConsumerRecord<String, String> record : records) {
if (replayed >= maxRecords) break;
reprocessingService.replayRecord(record);
replayed++;
}
return ResponseEntity.ok("Replayed " + replayed + " records from " + dltTopic);
}
}
- Add replay headers: tag replayed messages with a header so consumers can distinguish them from fresh records and avoid infinite DLT loops.
- Set a replay limit: track how many times a message has been replayed and stop after a threshold to prevent endless cycles.
- Replay in batches: if the DLT has thousands of records, replay in controlled batches to avoid overwhelming the target topic consumers.
- Validate before replay: if the root cause was a data issue, consider filtering or transforming DLT records before replaying them.