Kafka Listener Error Handling — SeekToCurrentErrorHandler
Fast recall
Quick revision
Core answer
Practical knowledge of error handling to prevent consumer stuck-states
Not configuring an error handler — infinite retry loops block the partition
Explain the difference between retryable and non-retryable exceptions, and the BackOff configuration
Interview-ready guide
Interview answer
When a listener throws, Spring Kafka's DefaultErrorHandler retries 10 times, then logs and moves on. The message is effectively lost unless you configure recovery.
Retries are blocking — they hold the consumer thread within the same poll cycle. If retries take too long, the consumer exceeds max.poll.interval.ms and triggers a rebalance.
Two production recovery strategies: Dead Letter Topic (publish failed messages to {topic}.DLT for later investigation) and non-blocking retry (@RetryableTopic with separate retry topics).
Mark poison pills (deserialization errors) as non-retryable — they'll never succeed. Send directly to DLT.
For batch listeners, BatchListenerFailedException identifies which record failed. The handler retries from that record forward.
In practice, I always replace the default handler with DefaultErrorHandler using ExponentialBackOff and DeadLetterPublishingRecoverer. No message should be silently lost.
Kafka Listener Error Handling — SeekToCurrentErrorHandler
Direct Answer
When a listener throws, Spring Kafka's DefaultErrorHandler retries the record up to a configurable limit, then sends it to a dead letter topic (DLT) or logs and skips it. The error handler seeks the consumer back to the failing offset for retry.
Code Example
Spring Kafka's DefaultErrorHandler (the modern replacement for SeekToCurrentErrorHandler) handles listener exceptions: retry the failing record with backoff, then publish to a Dead Letter Topic if all retries fail.
@Configuration
@RequiredArgsConstructor
public class KafkaErrorHandlingConfig {
private final KafkaTemplate<String, Object> template;
@Bean
public DefaultErrorHandler errorHandler() {
// Publish to <topic>.DLT with the failure metadata in headers
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template,
(rec, ex) -> new TopicPartition(rec.topic() + ".DLT", rec.partition()));
// Exponential backoff: 1s, 2s, 4s, 8s, 16s — total 5 retries (~31s)
ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(5);
backOff.setInitialInterval(1_000L);
backOff.setMultiplier(2.0);
backOff.setMaxInterval(16_000L);
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
// Non-retryable exceptions → straight to DLT, no retry
handler.addNotRetryableExceptions(
DeserializationException.class,
MessageConversionException.class,
ValidationException.class);
// Observability hooks
handler.setRetryListeners((record, ex, deliveryAttempt) ->
log.warn("Retry attempt {} for {} : {}",
deliveryAttempt, record.topic(), ex.getMessage()));
return handler;
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, Object> kafkaListenerContainerFactory(
ConsumerFactory<String, Object> cf, DefaultErrorHandler errorHandler) {
ConcurrentKafkaListenerContainerFactory<String, Object> f = new ConcurrentKafkaListenerContainerFactory<>();
f.setConsumerFactory(cf);
f.setCommonErrorHandler(errorHandler);
return f;
}
}
@Component
public class OrderListener {
@KafkaListener(topics = "orders", containerFactory = "kafkaListenerContainerFactory")
public void onOrder(OrderEvent ev) {
try { processor.process(ev); }
catch (TransientException e) { throw e; } // → retry
catch (PoisonException e) { throw new NotRetryableException(e); } // → DLT immediately
}
}
// DLT consumer for monitoring / manual replay
@KafkaListener(topics = "orders.DLT", groupId = "orders-dlt-monitor")
public void onDeadLetter(OrderEvent ev,
@Header(KafkaHeaders.DLT_EXCEPTION_MESSAGE) String reason,
@Header(KafkaHeaders.DLT_ORIGINAL_TOPIC) String origTopic,
@Header(KafkaHeaders.DLT_EXCEPTION_STACKTRACE) String trace) {
alerts.send("DLT received from " + origTopic + ": " + reason);
}
- Why "seek to current"? On retry, the consumer seeks back to the failing record's offset and re-polls — no offset commit advances until the record either succeeds or is recovered to DLT. Other records in the same poll batch are paused and replayed on success.
- Backoff matters — fixed 100 ms retries on a downstream outage spam the failing service. Use exponential backoff with a cap. Five attempts over ~30 seconds is a sane default.
- Mark non-retryable exceptions (deserialization, validation, business invariant) so they skip retry and go straight to DLT — re-running them will never succeed and just delays processing of healthy messages.
- The DLT carries forensic headers (
X-Original-Topic,X-Exception-Message,X-Exception-Stacktrace). Build a small DLT viewer to triage and a replay tool to resend after fixing the root cause. - Container-level vs listener-level error handlers:
setCommonErrorHandleron the factory applies to all listeners; per-listener override via@RetryableTopicfor finer control.
Common Mistakes & Pitfalls
Not configuring an error handler — infinite retry loops block the partition
Check your understanding
Answer these without looking back at the article.
- What is the difference between DefaultErrorHandler and SeekToCurrentErrorHandler?
- How do you replay messages from a Dead Letter Topic?
- How do you configure different error handlers for different listeners?
Related interview prep categories
2Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.