How Spring Kafka recovers from errors (DefaultErrorHandler, blocking vs non-blocking retries), configuring retry and backoff, implementing dead-letter topics, KafkaTemplate usage, guaranteed delivery between microservices, sagas on Kafka or RabbitMQ, the Schema Registry, multiple listeners on different topics, scaling consumers cloud-natively, and testing Kafka message flows.
Published September 25, 2026
Production Kafka code in Spring is mostly about what happens when things go wrong:
Show the exact Spring Kafka components (DefaultErrorHandler, DeadLetterPublishingRecoverer, @RetryableTopic), and the operational practices around them.
Short answer: When a @KafkaListener throws, the listener container's CommonErrorHandler decides what happens. The default is the DefaultErrorHandler:
BackOff (FixedBackOff/ExponentialBackOffWithMaxRetries). The partition is blocked while it retries, which preserves ordering, but stalls the other records in that partition.ConsumerRecordRecoverer runs, typically the DeadLetterPublishingRecoverer, which publishes to <topic>-dlt (or .DLT) with exception headers (the stack trace, the original topic, partition and offset). Then the offset is committed, and consumption continues.addNotRetryableExceptions(...). DeserializationException is wrapped by the ErrorHandlingDeserializer, so poison pills don't loop forever.BatchListenerFailedException(index), so only the failed record is retried or recovered.@RetryableTopic (Q2) routes failures to retry topics with delays, so the main partition keeps flowing.Learn it in depth → Retry & Backoff Strategies
Short answer:
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template,
(rec, ex) -> new TopicPartition(rec.topic() + ".DLT", rec.partition()));
var backOff = new ExponentialBackOffWithMaxRetries(4);
backOff.setInitialInterval(500); backOff.setMultiplier(2.0); backOff.setMaxInterval(10_000);
var handler = new DefaultErrorHandler(recoverer, backOff);
handler.addNotRetryableExceptions(ValidationException.class, JsonProcessingException.class);
return handler; // Boot wires a single CommonErrorHandler bean into the container factory
}
@RetryableTopic(attempts = "5",
backoff = @Backoff(delay = 1000, multiplier = 3.0, maxDelay = 60_000),
exclude = {ValidationException.class},
dltStrategy = DltStrategy.FAIL_ON_ERROR,
topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE)
@KafkaListener(topics = "orders.placed", groupId = "invoicing")
void on(OrderPlaced event) { invoices.create(event); }
@DltHandler
void dlt(OrderPlaced event, @Header(KafkaHeaders.EXCEPTION_MESSAGE) String error) { alerts.deadLetter(event, error); }
Choose blocking retries for quick transient errors where ordering matters, and non-blocking retry topics when downstream outages can last minutes, and per-key ordering can be relaxed. Always make processing idempotent, because retries mean duplicates.
Short answer:
DeadLetterPublishingRecoverer in the DefaultErrorHandler (blocking), or @RetryableTopic/@DltHandler (non-blocking). Failed records go to the DLT with diagnostic headers: kafka_dlt-exception-message, -stacktrace, -original-topic, -original-offset, and so on.ErrorHandlingDeserializer (the raw bytes go to the DLT, instead of an infinite loop).KafkaTemplate used for?Short answer: It's Spring's high-level producer API:
send(topic, key, value) (and send(ProducerRecord)/send(Message<?>)) returns a CompletableFuture<SendResult> (Spring Kafka 3). Handle the completion (log failures, metrics), and avoid blocking get() per message on hot paths;executeInTransaction(...), or with a KafkaTransactionManager/@Transactional (consume-process-produce EOS);RoutingKafkaTemplate (different producer configurations per topic);ProducerListener, for callbacks;Boot auto-configures it from spring.kafka.producer.* (serialisers, acks=all, idempotence, compression). With ReplyingKafkaTemplate, you get request-reply over Kafka (rarely a good idea).
Short answer: End-to-end reliability needs every hop covered:
acks=all, idempotence, retries, delivery.timeout.ms. Handle send failures.min.insync.replicas=2, unclean leader election disabled, and retention longer than the worst consumer downtime.The result is effectively-once outcomes, with at-least-once delivery.
Learn it in depth → Outbox Pattern
Short answer:
OrderCreated → Payment; PaymentCompleted → Inventory; InventoryFailed → Payment refunds, and Order cancels), performs a local transaction plus an outbox event, and publishes the next event. Compensations react to failure events.
order.created), and a queue per consumer service.reserve-inventory topics or queues), then consumes replies (inventory-replies), correlated by saga ID. Timeouts come from scheduled checks or delayed messages (a RabbitMQ delayed exchange, or Kafka retry topics). Or use frameworks: Axon, Eventuate Tram, Temporal (which handles durability without the broker plumbing).Learn it in depth → Saga Pattern
Short answer: A central service (Confluent Schema Registry, Apicurio, AWS Glue Schema Registry) that stores versioned schemas (Avro, Protobuf, JSON Schema) per subject (usually per topic value or key), and enforces compatibility rules (BACKWARD, FORWARD, FULL, and the transitive variants) when new versions are registered. How it works:
Why you need it:
Short answer:
@KafkaListener methods, each with its own topics (or topicPattern), groupId, concurrency, and even its own containerFactory (different deserialisers, error handlers, batch mode or ack mode):@KafkaListener(topics = "orders.placed", groupId = "billing", concurrency = "6")
void billing(OrderPlaced e) { ... }
@KafkaListener(topics = {"payments.completed", "payments.failed"}, groupId = "order-status",
containerFactory = "paymentsListenerFactory")
void paymentStatus(ConsumerRecord<String, PaymentEvent> rec) { ... }
@KafkaListener(topicPattern = "audit\\..*", groupId = "audit-archiver", batch = "true")
void archive(List<AuditEvent> events) { ... }
@KafkaListener with @KafkaHandler methods, dispatching by payload type.ConcurrentKafkaListenerContainerFactory beans for different configurations.autoStartup = "false" plus the KafkaListenerEndpointRegistry, to start, stop, pause or resume listeners at runtime.Short answer:
partitions per topic per consumer group. Size the partitions for peak throughput.concurrency (listener container threads, each a consumer). Across pods: replicas. The total consumers across replicas × concurrency should be at most the number of partitions.group.instance.id from the pod name in StatefulSets, or stable IDs);terminationGracePeriodSeconds);max.poll.interval.ms.Short answer:
@ServiceConnection), for real broker behaviour, or @EmbeddedKafka for speed.KafkaTemplate;KafkaTestUtils.getSingleRecord), or a mocked downstream call;auto.offset.reset=earliest.Q: What's the danger of retrying forever with blocking retries? A: A single poison message blocks the whole partition indefinitely. Consumer lag grows, and all the later messages (possibly for other keys) are stuck. Always cap the retries, and route to a DLT.
Q: What does AckMode.MANUAL_IMMEDIATE do?
A: The listener receives an Acknowledgment, and the offset is committed immediately when you call acknowledge(), which gives precise control (for example, acknowledging after an asynchronous completion). It carries the risk of forgetting to acknowledge, which causes redelivery after a rebalance.
Q: How do you replay DLT messages safely? A: Fix the root cause first. Then re-publish the DLT records to the original topic (a tool or admin endpoint that preserves keys and headers), or have a DLT consumer call the same idempotent handler. Monitor it, and cap the replay rate.
Q: Should event payloads contain full state, or only IDs? A: Both styles exist. Event-carried state transfer (full relevant state) lets consumers work without calling back, which is resilient and decoupled. Thin events (IDs only) force callbacks, which couples services and adds load. Include the data consumers commonly need, versioned.