Messaging Β· 04

Kafka with Spring

Spring for Apache Kafka turns the poll loop, offsets and retries of page 03 into a template and an annotation. This page explains what the container does under your listener, how to configure it on Spring Boot 3.5 (spring-kafka 3.3), how failures flow to retry topics and dead-letter topics, and shows real output from a lab project that was compiled and run.

13
Chapters
3.3.x
spring-kafka
3.5.x
Spring Boot
1
Runnable lab
01
Chapter One - Orientation

Spring for Apache Kafka: Versions, Dependencies and Configuration

Page 03 showed the plain Kafka clients: you create a KafkaProducer, write your own while (true) { poll(); ... } loop, decide when to commit, and deal with rebalances yourself. Spring for Apache Kafka (the spring-kafka library) wraps that work. It gives you a KafkaTemplate for sending and a listener container that owns the poll loop, threads, offset commits and error handling, so your code shrinks to a method annotated with @KafkaListener.

Where Spring Kafka sits: four layers between your code and the broker
Your code @KafkaListener methods, KafkaTemplate calls, a few beans Business logic, keys, topics, idempotency Spring Boot 3.5.x auto-configuration spring.kafka.* properties become factories, a template, a container factory Optional: you can build every bean by hand instead Spring for Apache Kafka 3.3.x KafkaTemplate, listener containers, error handlers, @RetryableTopic, serializers Threads, retries, DLT publishing, offset commits, Micrometer hooks kafka-clients 3.9.x KafkaProducer and KafkaConsumer: batching, poll loop, rebalancing, offsets The real protocol work (page 03) Kafka cluster (brokers, partitions, consumer group coordinator)

Spring Boot adds the last layer: it reads spring.kafka.* properties and creates the producer factory, consumer factory, KafkaTemplate, KafkaAdmin and a default listener container factory for you. Everything Boot does is plain Spring configuration, so when a setting is missing you can always define the bean yourself and Boot backs off.

Which versions go together

The pairing is decided by the Spring Boot dependency management, not by you. The table was read from the spring-boot-dependencies POMs on Maven Central on 2026-10-31.

Spring Bootspring-kafkakafka-clientsNotes
3.5.16 (latest 3.5.x checked)3.3.163.9.2This page. The code was compiled and run against exactly this set
3.5.143.3.153.9.2Same line, earlier patch
4.0.04.0.04.1.1Next major line. Same concepts, some packages and test classes changed (see the version note)
4.1.04.1.04.2.1Spring Kafka docs call 4.1.2 the latest stable at the time of checking

So "Boot 3.x" means spring-kafka 3.3.x and Kafka clients 3.9.x. The brokers you talk to can be newer (a 4.0 broker accepts 3.9 clients). On Boot 4.x the headline differences you will hit first are in testing: spring-kafka 4.0 removed the ZooKeeper-based embedded broker because Kafka 4.0 is KRaft only (chapter 10).

XML - Maven: the only Kafka dependency you add on Boot 3.x
<dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId> </dependency> <!-- tests --> <dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka-test</artifactId> <scope>test</scope> </dependency>
No version number needed
  • The Boot parent (or the spring-boot-dependencies BOM) manages spring-kafka, spring-kafka-test and kafka-clients. Pinning kafka-clients yourself is the usual way to end up with a client Spring Kafka was not tested against.
The spring.kafka.* properties that matter

Boot maps most Kafka client settings to typed properties. The names below were checked against the Spring Boot 3.5 application-properties appendix. Anything not listed can be passed through with spring.kafka.producer.properties.*, spring.kafka.consumer.properties.*, spring.kafka.admin.properties.* or the common spring.kafka.properties.*.

PropertyWhat it controlsNotes
spring.kafka.bootstrap-serversInitial broker list for all clientsComma-separated host:port. Can be overridden per client type
spring.kafka.consumer.group-idDefault consumer groupA @KafkaListener(id = "x") overrides it (chapter 3)
spring.kafka.consumer.auto-offset-resetWhere a new group startsKafka default is latest; the lab uses earliest
spring.kafka.consumer.enable-auto-commitKafka-side periodic commitThe container sets it to false unless you set it explicitly. Leave it alone
spring.kafka.consumer.max-poll-records, max-poll-intervalBatch size and the allowed time between pollsKafka defaults 500 and 5 minutes (checked on the Kafka 3.9 consumer config page)
spring.kafka.consumer.isolation-levelRead committed transactional data or notKafka default read_uncommitted
spring.kafka.consumer.key-deserializer, value-deserializerDeserializer classesUse ErrorHandlingDeserializer as the outer one (chapter 5)
spring.kafka.producer.key-serializer, value-serializerSerializer classesOften StringSerializer and JsonSerializer
spring.kafka.producer.acks, retries, batch-size, compression-typeTyped producer settingsIdempotence is on by default in Kafka 3.x clients when settings do not conflict
spring.kafka.producer.transaction-id-prefixTurns on producer transactionsAlso auto-configures a KafkaTransactionManager (chapter 8)
spring.kafka.listener.ack-modeWhen the container commits offsetsContainer default is BATCH (chapter 4)
spring.kafka.listener.concurrencyConsumer threads per listenerCapped by partition count (chapter 3)
spring.kafka.listener.typesingle or batchDefault single
spring.kafka.listener.poll-timeoutTimeout of each poll() callContainer default is 5 seconds
spring.kafka.listener.observation-enabled, spring.kafka.template.observation-enabledMicrometer observations and tracesDefault false (chapter 10)
spring.kafka.retry.topic.enabled, attempts, backoff.*Global non-blocking retry configenabled default false, attempts default 3, backoff.delay default 1s (chapter 7)
spring.kafka.template.default-topicTopic for sendDefault(...)
YAML - the lab application.yml (JSON values, record acks, three consumer threads)
spring: datasource: url: jdbc:h2:mem:demo;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 sql: init: mode: always kafka: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer acks: all properties: spring.json.add.type.headers: false consumer: group-id: inventory auto-offset-reset: earliest key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer properties: spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer spring.json.value.default.type: demo.OrderPlaced spring.json.trusted.packages: demo listener: ack-mode: record concurrency: 3

Three details in this file repay a minute. Properties with dots in their names (spring.json.trusted.packages) are plain strings under properties:; Boot passes them to the client config untouched. ack-mode: record is a container setting, not a Kafka one. And the common vs per-client rule: spring.kafka.bootstrap-servers applies to producer, consumer and admin unless a client-specific property overrides it.

Topics as beans

When a KafkaAdmin bean exists (Boot creates one), every NewTopic bean in the context is created on startup if missing. TopicBuilder is the convenient way to declare it:

Java - declare a topic next to the code that uses it
@Bean NewTopic orders() { return TopicBuilder.name("orders") .partitions(6) .replicas(3) .build(); }
⚠ Letting auto-creation decide partitions
  • If the broker auto-creates a topic on first use it gets the broker defaults (often 1 partition). Concurrency then does nothing (chapter 3) and fixing it later changes key-to-partition mapping for keyed topics (page 02). Declare production topics explicitly, or through your infrastructure code, and keep NewTopic beans for local and test setups.
02
Chapter Two - Producing

KafkaTemplate: Sending, Callbacks, Keys, Headers

KafkaTemplate<K, V> is a thin, thread-safe wrapper around a shared KafkaProducer. Every send variant returns a CompletableFuture<SendResult<K, V>> (it was a ListenableFuture before Spring Kafka 3.0). The overloads checked in the 3.3 reference are send(topic, value), send(topic, key, value), send(topic, partition, key, value), the same with a timestamp, send(ProducerRecord), send(Message<?>) and the sendDefault(...) family that uses spring.kafka.template.default-topic.

What happens when you call template.send(...)
Your thread template.send( key, value) Serialize key and value to byte[] Partition hash(key) mod partitions Batch buffer per partition, waits linger.ms Sender thread network I/O to the leader Broker leader append, replicate send ack (or error after retries) CompletableFuture completes here whenComplete(...) callback thread send() returns immediately with the future. The record may still be sitting in the batch buffer. If the application dies after send() returned, a record that was only buffered is lost. Failure after delivery.timeout.ms (default 2 minutes) completes the future exceptionally.
Send and react to the result
Java - async callback (preferred)
public void sendWithCallback(OrderPlaced event) { CompletableFuture<SendResult<Object, Object>> future = template.send("orders", event.orderId(), event); // key = orderId future.whenComplete((result, ex) -> { if (ex != null) { log.error("send failed for {}", event.eventId(), ex); // retries already exhausted } else { var md = result.getRecordMetadata(); log.info("sent to {}-{}@{}", md.topic(), md.partition(), md.offset()); } }); }
Java - wait for the broker (blocks the calling thread)
public void sendAndWait(OrderPlaced event) throws Exception { template.send("orders", event.orderId(), event) .get(10, java.util.concurrent.TimeUnit.SECONDS); // blocks: use sparingly }

Block with get() when the caller must not continue unless the broker has the record (a request handler that returns 202 only after the event is stored, for example). Do not call get() per record inside a loop to "make it safe": you serialize the whole producer to one in-flight request at a time and lose batching, which is the main source of Kafka throughput.

Keys and headers
  • The key decides the partition, hence the ordering scope. Use the entity id (orderId) for everything that must stay ordered per entity. A null key spreads records over partitions with no per-entity order (page 02).
  • Headers carry metadata that is not part of the business payload: an event id for deduplication, a correlation id, a schema version. Add them on a ProducerRecord or build a Message with MessageBuilder.
Java - two ways to attach headers
public void sendWithHeaders(OrderPlaced event) { var record = new ProducerRecord<Object, Object>("orders", null, event.orderId(), event); record.headers().add(new RecordHeader("event-id", event.eventId().getBytes())); template.send(record); Message<OrderPlaced> msg = MessageBuilder.withPayload(event) .setHeader(KafkaHeaders.TOPIC, "orders") .setHeader(KafkaHeaders.KEY, event.orderId()) .setHeader("event-id", UUID.randomUUID().toString()) .build(); template.send(msg); }
QuestionAnswer
Is send() blocking?Only until the record is handed to the producer buffer. If metadata for the topic is not yet known, the call can block up to max.block.ms first
What retries a failed send?The producer itself, up to delivery.timeout.ms (default 2 minutes, Kafka 3.9). The future fails only after that
Are duplicates possible from producer retries?Not with the default idempotent producer (enable.idempotence defaults to true and the lab log shows acks = -1, enable.idempotence = true)
Is the template thread-safe?Yes. One template per application is normal
Can I send from inside a listener?Yes, and with transactions the send and the offset commit can be atomic (chapter 8)
⚠ Ignoring the future
  • A bare template.send(...) whose result nobody looks at hides failures: a broker outage, RecordTooLargeException or serialization error turns into a lost event with nothing in the logs of your own code. Always attach whenComplete, a ProducerListener, or get(). For events that must reach Kafka whenever a database write succeeded, use the transactional outbox (page 07), not a clever callback.
03
Chapter Three - Consuming

@KafkaListener and the Listener Container

@KafkaListener marks a method as a message handler. Spring Kafka registers it with a container factory, which builds a **ConcurrentMessageListenerContainer**. That container creates concurrency child KafkaMessageListenerContainer instances, and each child runs one KafkaConsumer on its own thread. All of them join the same consumer group, so Kafka spreads the partitions over them.

Java - the smallest useful listener (the lab application)
@Component @Profile("blocking") public class OrderListener { private static final Logger log = LoggerFactory.getLogger(OrderListener.class); final List<String> seen = new CopyOnWriteArrayList<>(); private final StockService stock; public OrderListener(StockService stock) { this.stock = stock; } @KafkaListener(id = "inventory", topics = "orders") public void onOrder(OrderPlaced event, @Header(KafkaHeaders.RECEIVED_PARTITION) int partition, @Header(KafkaHeaders.OFFSET) long offset) { boolean first = stock.reserveOnce(event); log.info("orders-{}@{} {} -> {}", partition, offset, event.eventId(), first ? "reserved" : "duplicate, skipped"); seen.add(event.eventId() + ":" + (first ? "reserved" : "duplicate")); } }
Concurrency is capped by partitions: extra threads sit idle
Topic orders has 6 partitions concurrency = 3: three consumer threads, each owns two partitions p0 p1 p2 p3 p4 p5 thread 1 thread 2 thread 3 Each thread = one KafkaConsumer in the same consumer group. concurrency = 8 on the same topic: only 6 threads get a partition p0 thread 1 p1 thread 2 p2 thread 3 p3 thread 4 p4 thread 5 p5 thread 6 thread 7 thread 8 idle: no partition left To go faster you need more partitions, or faster processing per record. More threads alone change nothing.

The method parameters are resolved by Spring Messaging: the payload is converted from the record value, and @Header pulls out record metadata. The ones used most are KafkaHeaders.RECEIVED_TOPIC, RECEIVED_PARTITION, RECEIVED_KEY, OFFSET and RECEIVED_TIMESTAMP. You can also take the whole ConsumerRecord<K, V> when you need raw access.

@KafkaListener attributeMeaningGotcha
idContainer id, and the consumer group idSince Spring Kafka 2.0 id overrides the group id from the consumer factory. Set groupId explicitly, or idIsGroup = false, if you want something else
groupIdExplicit consumer groupTwo listeners with the same group on one topic share its partitions; different groups each get every record
topics, topicPattern, topicPartitionsWhat to subscribe toExplicit topicPartitions skip group management: no rebalances, no failover
concurrencyThreads for this listenerOverrides the factory value. Capped by partitions in practice
containerFactoryWhich factory builds the containerUse a second factory for a different ack mode or a batch listener
batchReceive a List per pollOverrides the factory batchListener flag (since 2.8)
propertiesConsumer properties for just this listenergroup.id and client.id cannot be set this way
autoStartupStart with the context or laterStart it through KafkaListenerEndpointRegistry
Concurrency, spelled out
  • Each thread polls its own partitions, so ordering inside a partition is preserved: one thread, one partition, records in order.
  • With concurrency = 3 on a 6-partition topic each thread owns two partitions. With concurrency = 8 two threads get nothing (diagram above).
  • Running two application instances with concurrency 3 each also splits 6 partitions into 6 consumers. Total consumers across all instances should not exceed the partition count.
  • For several topics the default RangeAssignor can leave threads idle even when there are enough partitions overall; the reference suggests a RoundRobinAssignor through partition.assignment.strategy for that case.
Sizing rule
  • Choose partitions first (page 02: from target throughput and the slower side), then set concurrency to partitions divided by the number of instances, rounded up.
04
Chapter Four - Under the Container

The Poll Loop, Ack Modes and Batch Listeners

The container is the poll loop from page 03, packaged. Each child container runs: poll(), hand the returned records to your listener on the same thread, commit offsets according to the ack mode, poll() again. That single-thread design explains most behaviour you will meet: the listener is never called concurrently for one partition, a slow listener delays the next poll, and the time budget for a whole batch is max.poll.interval.ms.

The poll loop the container runs for you
poll(timeout) up to 500 records Listener called per record or batch Commit offsets, per ack mode Back to poll() same thread, loops forever The clock that kills you: time between two poll() calls all listener calls for this batch + commits + any error-handler backoff max.poll.interval.ms default 300000 (5 min) Over the limit: the consumer is considered failed, the group rebalances, the partitions move to another consumer and your slow listener is still running. When it finally commits, the commit is rejected (page 03).
Ack modes

Spring Kafka sets enable.auto.commit to false unless you set it yourself and commits through the container. The ack mode says when. The default is BATCH. Commits use commitSync unless you turn off syncCommits (default true).

AckModeOffset is committed...If the app crashes mid-wayUse when
RECORDafter the listener returns for each recordAt most one record is reprocessedPer-record safety matters more than commit overhead
BATCH (default)after all records of one poll() were processedUp to max.poll.records (default 500) records are reprocessedHigh throughput, idempotent handlers
TIME, COUNT, COUNT_TIMEafter a batch, once ackTime and/or ackCount has passedMore reprocessing, fewer commitsCommit cost is a real bottleneck
MANUALafter you call ack.acknowledge(), with BATCH semanticsRecords not acknowledged are redeliveredCommit only after an async step finished
MANUAL_IMMEDIATEright when acknowledge() is calledSame, with the commit sent at onceYou need precise control

All of these are at-least-once: the commit happens after the work, so a crash between work and commit replays records. That is why chapter 8 adds an idempotent listener. Spring never commits before the listener returns in any of these modes.

Manual acknowledgment
Java - a second container factory with MANUAL acks, and a listener that uses it
@Configuration @Profile("snippets") public class ManualAckConfig { @Bean public ConcurrentKafkaListenerContainerFactory<Object, Object> manualAckFactory( ConcurrentKafkaListenerContainerFactoryConfigurer configurer, ConsumerFactory<Object, Object> consumerFactory) { var factory = new ConcurrentKafkaListenerContainerFactory<Object, Object>(); configurer.configure(factory, consumerFactory); // keep the spring.kafka.* settings factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); return factory; } }
Java - acknowledge after the work succeeded
@KafkaListener(id = "manual", topics = "orders", containerFactory = "manualAckFactory") public void manual(OrderPlaced event, Acknowledgment ack) { stock.reserveOnce(event); ack.acknowledge(); // commits after the work, not before }

Acknowledgment also has nack(Duration) (and index variants for batches) that makes the container discard the rest of the polled records, pause for the given time, and redeliver from the failed record. Manual modes require a listener that accepts the Acknowledgment parameter; with MANUAL and MANUAL_IMMEDIATE and async-acks you can even acknowledge out of order, but then see the out-of-order commit section of the reference before relying on it.

Batch listeners

A batch listener receives everything one poll() returned as a List. Set spring.kafka.listener.type: batch, factory.setBatchListener(true), or batch = "true" on one annotation. It suits bulk writes (insert 500 rows in one statement).

Java - batch listener that reports exactly which record failed
@KafkaListener(id = "batch", topics = "orders", batch = "true") public void batch(List<ConsumerRecord<String, OrderPlaced>> records) { for (int i = 0; i < records.size(); i++) { try { stock.reserveOnce(records.get(i).value()); } catch (Exception e) { // records before index i are committed, i and after are redelivered throw new BatchListenerFailedException("failed at " + i, e, i); } } }

Throwing BatchListenerFailedException with the failing index lets the DefaultErrorHandler commit the earlier records, retry or recover only the bad one, and redeliver the rest. Any other exception makes it retry the whole batch. Remember the limits: non-blocking retries (@RetryableTopic) do not work with batch listeners, and Micrometer observations do not support them either (timers are used instead).

05
Chapter Five - Serialization

JSON, ErrorHandlingDeserializer and Trusted Packages

The wire format is bytes; your code wants objects. Spring Kafka ships JsonSerializer and JsonDeserializer (Jackson). Using them correctly takes three decisions: how the deserializer learns the target type, which packages it may instantiate, and what happens when a record cannot be deserialized at all.

ConcernProperty (Spring Kafka 3.3)Default / note
Add __TypeId__ header when producingspring.json.add.type.headerstrue. Set false when consumers should not depend on your class names
Fallback type when no headerspring.json.value.default.typeRequired when you turn type headers off
Ignore the type header when consumingspring.json.use.type.headerstrue
Allowed packages for deserializationspring.json.trusted.packagesjava.util, java.lang only. * allows everything
Map logical names to classesspring.json.type.mapping on both sidesExample: order:com.shop.OrderPlaced. Avoids sharing class names across services
Wrap a failing deserializerOuter ErrorHandlingDeserializer plus spring.deserializer.value.delegate.classSee below
The trusted packages pitfall

The JsonDeserializer refuses to build classes from packages it was not told to trust, because a type name read from a message header is attacker-controlled input. With the default trusted list (java.util, java.lang) your first JSON message of type com.shop.OrderPlaced fails with an exception that says the class is not in the trusted packages. The quick fix people find is *. Prefer the narrow fix:

YAML - list the packages, or avoid the header altogether
spring: kafka: consumer: properties: spring.json.trusted.packages: com.shop.events # explicit list # or ignore sender class names and always bind to one type: # spring.json.use.type.headers: false # spring.json.value.default.type: com.shop.events.OrderPlaced
⚠ trusted.packages=* in production
  • It makes the consumer instantiate any class named in a header that exists on the classpath. Keep an explicit list or pin one default type with use.type.headers=false.
ErrorHandlingDeserializer: poison pills that do not loop

A deserializer runs **inside poll()**, before your listener or any Spring error handler sees the record. If it throws, the consumer cannot return that record, does not advance, and the same record fails again on the next poll: an endless loop at 100 percent CPU that no listener try/catch can touch. ErrorHandlingDeserializer wraps the real deserializer, catches the exception and returns null plus a DeserializationException header that holds the cause and the raw bytes. For a record listener the container then sends the record to the error handler instead of the listener.

YAML - the pieces from the lab configuration
value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer properties: spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer spring.json.value.default.type: demo.OrderPlaced spring.json.trusted.packages: demo

Do the same for keys with ErrorHandlingDeserializer and spring.deserializer.key.delegate.class if keys can be malformed. In the lab, a record whose value is the text this is not json landed in the dead-letter topic with its original bytes intact (chapter 6).

The DLT template must serialize byte[]

After a deserialization failure the record value is the raw byte[]. If the KafkaTemplate that publishes to the dead-letter topic uses JsonSerializer, those bytes get JSON-encoded (base64 inside quotes) and the original payload is damaged. The reference recommends a template of type <Object, Object> with a DelegatingByTypeSerializer that handles byte[] separately. This is the template the lab uses for both the app and the recoverer:

Java - one template for events and for raw bytes
/** * One template that can serialize both normal events and the raw byte[] values that come * out of an ErrorHandlingDeserializer failure. Both the application and the * DeadLetterPublishingRecoverer / @RetryableTopic use it. */ @Configuration public class KafkaBeans { @Bean public ProducerFactory<Object, Object> producerFactory(KafkaProperties props) { Map<String, Object> cfg = props.buildProducerProperties(null); // a serializer INSTANCE configured with setters must not also receive spring.json.* properties cfg.remove("spring.json.add.type.headers"); JsonSerializer<Object> json = new JsonSerializer<>(); json.setAddTypeInfo(false); var valueSerializer = new DelegatingByTypeSerializer(Map.of( byte[].class, new ByteArraySerializer(), String.class, new StringSerializer(), OrderPlaced.class, json)); var keySerializer = new DelegatingByTypeSerializer(Map.of( byte[].class, new ByteArraySerializer(), String.class, new StringSerializer())); return new DefaultKafkaProducerFactory<>(cfg, keySerializer, valueSerializer); } @Bean public KafkaTemplate<Object, Object> kafkaTemplate(ProducerFactory<Object, Object> pf) { return new KafkaTemplate<>(pf); } }

One more thing the lab taught: when you pass a serializer instance that you configured with setters (setAddTypeInfo(false)), it must not also receive spring.json.* properties. Spring Kafka throws JsonSerializer must be configured with property setters, or via configuration properties; not both. The cfg.remove(...) line above is the fix.

Schemas
  • Plain JSON has no enforced schema. For contracts between teams look at a schema registry with Avro or Protobuf serializers; they plug into the same value-serializer and value-deserializer properties.
06
Chapter Six - Failure Handling

DefaultErrorHandler, BackOff and Dead-Letter Topics

When a record listener throws, the container hands the exception to its CommonErrorHandler. The default is a **DefaultErrorHandler**, and out of the box it is *blocking*: it seeks back to the failed record, redelivers it after a back-off, and keeps the partition stalled until the record succeeds or attempts run out. This keeps ordering intact. It also means one bad record holds up every record behind it in that partition.

DefaultErrorHandler: what the container does when a listener throws
Listener throws Fatal exception type? not retryable list BackOff wait, seek back redeliver the same record no Attempts left? BackOff exhausted? fail again yes: call listener Recoverer DLPR: publish to topic-dlt no more yes: skip retries Commit offset move to next record Defaults: FixedBackOff(0, 9) = no wait, 10 attempts, then the record is logged at ERROR and skipped. A DeadLetterPublishingRecoverer replaces that log-and-skip with publish-and-skip. While the handler backs off it pauses and polls, so the consumer stays alive (no rebalance).
PieceWhat it doesDefault (Spring Kafka 3.3 reference)
BackOffDelay and number of redeliveriesFixedBackOff(0L, 9): no delay, 10 deliveries in total
RecovererWhat happens when attempts are exhaustedLog at ERROR and skip. Use DeadLetterPublishingRecoverer to publish instead
Not-retryable exceptionsSkip retries, call the recoverer on the first failureDeserializationException, MessageConversionException, ConversionException, MethodArgumentResolutionException, NoSuchMethodException, ClassCastException. Add your own with addNotRetryableExceptions(...)
seekAfterErrorRe-seek vs keep records in memory while backing offtrue. Set false to resubmit the already polled records without seeking
Batch listenersThrow BatchListenerFailedException(msg, index)Any other exception retries the whole batch
Wiring it up

With Boot you only declare a CommonErrorHandler bean: Boot attaches it to the auto-configured container factory (the same applies to RecordFilterStrategy, AfterRollbackProcessor and a rebalance listener). This is the configuration used in the lab:

Java - exponential back-off, one non-retryable business exception, DLT as recoverer
@Configuration @Profile("blocking") public class BlockingRetryConfig { @Bean public CommonErrorHandler errorHandler(KafkaTemplate<Object, Object> template) { // Default destination: <original topic>-dlt, same partition as the original record var recoverer = new DeadLetterPublishingRecoverer(template); var backOff = new ExponentialBackOffWithMaxRetries(3); // 3 retries = 4 deliveries backOff.setInitialInterval(200L); backOff.setMultiplier(2.0); backOff.setMaxInterval(2_000L); var handler = new DefaultErrorHandler(recoverer, backOff); handler.addNotRetryableExceptions(InvalidOrderException.class); return handler; } }

ExponentialBackOffWithMaxRetries(3) retries after 200, 400 and 800 ms (multiplier 2, capped at 2 s) before giving up, so four deliveries in total. A plain ExponentialBackOff retries indefinitely unless you compute a maxElapsedTime, which is why Spring Kafka provides the ...WithMaxRetries subclass.

⚠ Back-off longer than max.poll.interval.ms
  • A blocking back-off stalls the partition for its whole length, and the container still has to keep polling. The reference points to ContainerPausingBackOffHandler (pauses the container instead of sleeping) for delays longer than max.poll.interval.ms. If you need minutes or hours between attempts, do not block: use retry topics (chapter 7).
The dead-letter publisher

DeadLetterPublishingRecoverer needs a KafkaTemplate<Object, Object>. By default it publishes to **<original topic>-dlt on the same partition number** as the original record, so the DLT needs at least as many partitions as the source topic. Pass a destination function (record, exception) -> TopicPartition to route differently; a negative partition lets Kafka choose.

In the lab three bad records were sent to orders: one with quantity 0 (throws the non-retryable InvalidOrderException), one that is not JSON, and a duplicate of a good one. This is what the dead-letter records looked like when read back with a plain consumer (abridged: stack traces shortened, binary headers decoded by the test):

Text - abridged real output from the lab (spring-kafka 3.3.16)
-- orders-dlt-0@0 key=o-2 value={"eventId":"e-2","orderId":"o-2","qty":0} kafka_dlt-exception-fqcn = org.springframework.kafka.listener.ListenerExecutionFailedException kafka_dlt-exception-cause-fqcn = demo.InvalidOrderException kafka_dlt-exception-message = Listener method 'public void demo.OrderListener.onOrder(...)' threw ... kafka_dlt-exception-stacktrace = org.springframework.kafka.listener.ListenerExecutionFailedException: ... kafka_dlt-original-topic = orders kafka_dlt-original-partition = 0 (stored as 4 binary bytes) kafka_dlt-original-offset = 2 (stored as 8 binary bytes) kafka_dlt-original-timestamp = ... (8 binary bytes) kafka_dlt-original-timestamp-type = CreateTime kafka_dlt-original-consumer-group = inventory -- orders-dlt-1@0 key=o-3 value=this is not json kafka_dlt-exception-fqcn = org.springframework.kafka.support.serializer.DeserializationException kafka_dlt-exception-message = failed to deserialize kafka_dlt-original-topic = orders kafka_dlt-original-consumer-group = inventory
  • The value is the original payload (for the bad JSON, the original bytes). That is what lets you fix and replay it.
  • The record landed in orders-dlt-0 and orders-dlt-1: the same partition numbers as in orders (keys o-2 and o-3 hash differently).
  • The headers answer the four questions of an incident: what failed (exception class, message, stack trace), where it came from (topic, partition, offset, timestamp) and who failed to process it (consumer group). Combine topic, partition and offset to find the record in the source topic while retention still holds it.
Alert on the DLT
  • A dead-letter topic nobody reads is a silent data-loss mechanism with extra steps. Alert on its end offset growing (consumer lag on a DLT consumer, or a metric on records produced) and give it a retention long enough for a human to react.
07
Chapter Seven - Non-Blocking Retries

@RetryableTopic: Retry Topics Instead of Blocking

Blocking retries protect ordering and punish throughput. When a failure is transient (a gateway is down for a minute) and strict order across records does not matter, you can move the failing record out of the way: publish it to a retry topic with a "not before" timestamp, commit the original offset, and keep going. Spring Kafka (since 2.7) builds this with @RetryableTopic; the container for each retry topic pauses the partition until the record is due.

How the pattern works
  • The listener fails: the record is published to the first retry topic with a back-off timestamp header, and the main offset is committed.
  • A consumer of the retry topic checks the timestamp. If it is not yet due it pauses that partition and resumes when due. It is never processed before its due time; it can be late if the consumer is busy (best effort).
  • On failure again the record moves to the next retry topic. When attempts are exhausted it goes to the dead-letter topic (DLT), where an optional @DltHandler method consumes it.
  • The framework creates the topics (through KafkaAdmin) and the listener containers for you.
Java - the listener from the lab: 4 attempts, 500 ms doubling, one excluded exception
@Component @Profile("retry") public class PaymentListener { private static final Logger log = LoggerFactory.getLogger(PaymentListener.class); static final long START = System.currentTimeMillis(); final List<String> trail = new CopyOnWriteArrayList<>(); @RetryableTopic( attempts = "4", backoff = @Backoff(delay = 500, multiplier = 2.0), kafkaTemplate = "kafkaTemplate", exclude = InvalidOrderException.class) @KafkaListener(id = "payments", topics = "payments") public void onPayment(OrderPlaced event, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic, @Header(name = RetryTopicHeaders.DEFAULT_HEADER_ATTEMPTS, required = false) Integer attempt) { log.info("{} attempt={} {}", topic, attempt, event.eventId()); trail.add("+" + (System.currentTimeMillis() - START) + "ms " + topic + " attempt=" + attempt + " " + event.eventId()); if (event.qty() <= 0) { throw new InvalidOrderException("qty must be positive"); } if (event.qty() == 99) { throw new IllegalStateException("card gateway unavailable"); } } @DltHandler public void onDead(OrderPlaced event, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic, @Header(KafkaHeaders.EXCEPTION_FQCN) String exception, @Header(KafkaHeaders.ORIGINAL_TOPIC) String originalTopic) { log.info("DLT {} <- {} ({}) {}", topic, originalTopic, exception, event.eventId()); trail.add("+" + (System.currentTimeMillis() - START) + "ms " + topic + " original=" + originalTopic + " " + exception + " " + event.eventId()); } }
@RetryableTopic attributeDefaultMeaning
attempts3Total tries including the first one: 4 means one original plus three retries
backoff@Backoff 1000 ms fixedSpring Retry @Backoff (delay, multiplier, maxDelay, random)
include / excluderetry allexclude sends the exception straight to the DLT. Add traversingCauses = "true" to look at nested causes
kafkaTemplatebean defaultRetryTopicKafkaTemplateThe reference says an exception is thrown if that bean does not exist, so name your template explicitly. It must serialize both events and byte[]
dltTopicSuffix, retryTopicSuffix-dlt, -retryTopic naming
dltStrategyALWAYS_RETRY_ON_ERRORWhat happens if the DLT handler itself throws (see the trap below). Other values: FAIL_ON_ERROR, NO_DLT
numPartitions, replicationFactor, autoCreateTopics1, -1 (broker default), true (read from the 3.3.16 annotation)Retry topics are published to the same partition number as the source record, so a one-partition retry topic breaks a multi-partition source. Raise numPartitions or set a partition resolver
concurrencysame as main containerSince 3.0 you can set different concurrency for retry containers
timeoutnoneGlobal limit for the whole retry process
Topic naming

With a main topic payments, attempts = 4 and exponential back-off from 500 ms the framework created exactly these topics in the lab:

Text - real topic list from the lab (internal topics removed)
[payments, payments-dlt, payments-retry-1000, payments-retry-2000, payments-retry-500]

The rules from the reference: one retry topic per retry (attempts - 1 of them), suffixed with the delay for varying delays and with the index (-retry-0, -retry-1) for fixed delays. With a fixed back-off you can reuse a single retry topic (sameIntervalTopicReuseStrategy = SINGLE_TOPIC), and since 3.2 an exponential back-off reuses the last topic for every attempt at maxDelay, which makes long retry schedules (hundreds of attempts) practical.

Boot properties for the global default
YAML - turn on non-blocking retries for every listener without annotations
spring: kafka: retry: topic: enabled: true # default false attempts: 4 # default 3, counts the first try backoff: delay: 500ms # default 1s multiplier: 2 max-delay: 5s
Trade-offs you accept
GainPrice
One bad or temporarily failing record no longer blocks the partitionOrdering is lost for that topic. A retried record is processed after records that came later. The reference states it plainly: you lose Kafka ordering guarantees for the topic
Long back-offs without keeping a consumer busyMore topics (and partitions) to create, monitor and size; extra consumer groups (named after your group plus the topic suffix)
Clear DLT at the end with the original payloadNot supported with batch listeners and cannot be combined with container transactions
⚠ A DLT handler that depends on the wrong headers
  • The first version of the lab handler read KafkaHeaders.DLT_EXCEPTION_FQCN. Retry-topic records carry the non-prefixed names (kafka_exception-fqcn, kafka_original-topic, ...), because the retry-topic infrastructure publishes with a different header set than a plain DeadLetterPublishingRecoverer (kafka_dlt-exception-fqcn, ...). The handler failed with Missing header, and with the default ALWAYS_RETRY_ON_ERROR the record was published to the DLT again and again, filling the log in under a minute. Use KafkaHeaders.EXCEPTION_FQCN and ORIGINAL_TOPIC in this flow, mark optional headers required = false, and keep DLT handlers trivially safe.
Accessing the attempt number
  • Add @Header(name = RetryTopicHeaders.DEFAULT_HEADER_ATTEMPTS, required = false) Integer attempt to the listener. It is null on the first delivery. KafkaMessageHeaderAccessor (since 3.0.10) returns 1 for the initial delivery and also exposes blocking attempts.
08
Chapter Eight - Exactly-Once Territory

Idempotent Listeners and Kafka Transactions in Spring

Every mode above is at-least-once, so a listener will occasionally see a record twice (a rebalance after processing but before the commit, a retry after a partial success, a producer resend). Page 01 introduced the cure: record each processed message id in a dedup table in the same database transaction as the business effect. Here it is in the lab service, running on an in-memory H2 database:

Java - claim the event id and do the work in one local transaction (tested)
@Service public class StockService { private final JdbcTemplate jdbc; public StockService(JdbcTemplate jdbc) { this.jdbc = jdbc; } /** Claim the event id and do the work in one local transaction. */ @Transactional public boolean reserveOnce(OrderPlaced event) { int claimed = jdbc.update( "INSERT INTO processed_message (message_id, consumer) " + "SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM processed_message WHERE message_id = ?)", event.eventId(), "inventory", event.eventId()); if (claimed == 0) { return false; // duplicate delivery: skip the work } if (event.qty() <= 0) { throw new InvalidOrderException("qty must be positive: " + event.qty()); } jdbc.update("INSERT INTO stock_reservation (order_id, qty) VALUES (?, ?)", event.orderId(), event.qty()); return true; } }

The listener calls it and logs the outcome. The lab sent the same event e-1 twice. This is the real log:

Text - lab log (two lines kept)
orders-0@0 e-1 -> reserved orders-0@1 e-1 -> duplicate, skipped

One stock row for o-1, not two. Two notes on the SQL: the INSERT ... SELECT ... WHERE NOT EXISTS form runs on both H2 and PostgreSQL (H2 rejected the ON CONFLICT form from page 01 in the lab), and the primary key still protects against two threads racing: the loser gets a constraint violation, the transaction rolls back, the record is retried and then recognised as a duplicate.

  • Use an event id created by the producer, not the offset (offsets change if a topic is rebuilt).
  • Delete dedup rows only after the longest possible redelivery window (retention plus retry delays).
  • When the listener must also publish a follow-up event atomically with its database write, do not call template.send inside the DB transaction and hope. Write the event to an outbox table in the same transaction and let a relay publish it. That pattern is page 07 (Event-Driven Patterns).
Kafka transactions: exactly-once inside Kafka

Kafka transactions make a consume, process, produce cycle atomic *within Kafka*: the produced records and the consumed offsets commit together, or neither does (page 03 shows the client API). In Spring you switch it on with one property, and Boot does the wiring.

YAML - producer transactions and read_committed downstream
spring: kafka: producer: transaction-id-prefix: tx-inventory-${HOSTNAME}- # unique per application instance consumer: isolation-level: read_committed # for consumers of transactional topics
  • Setting transaction-id-prefix makes the producer factory transactional and Boot creates a **KafkaTransactionManager**, wired into the default container factory. The listener then runs inside a Kafka transaction and the consumed offsets are sent to that transaction.
  • Each producer in the factory gets transactional.id = prefix + n. With several application instances the prefix must be unique per instance (the reference says so; Spring Kafka 3.x supports only EOSMode.V2, which no longer needs the same id across restarts).
  • Consumers see aborted records unless they set isolation.level=read_committed (Kafka default is read_uncommitted).
  • Outside a listener, @Transactional methods and template.executeInTransaction(...) start a producer-initiated transaction.
Java - consume, update the database, publish, with a Kafka transaction around the listener
@Transactional("transactionManager") // the DB manager; the container supplies the Kafka tx public void consumeAndProduce(OrderPlaced event) { if (stock.reserveOnce(event)) { template.send("stock-reserved", event.orderId(), event); } }

This is the arrangement the reference recommends now that ChainedKafkaTransactionManager is deprecated: the container starts the Kafka transaction through the KafkaTransactionManager, and the @Transactional method starts the database transaction. Name the database manager explicitly: with two PlatformTransactionManager beans an unqualified @Transactional is ambiguous.

QuestionHonest answer
Is the database part of the Kafka transaction?No. Two separate transactions that commit one after the other. The database transaction is nested inside the Kafka one, so it commits first and the Kafka transaction right after (this ordering is a consequence of the nesting, not a quote from the docs)
What if the Kafka commit fails after the DB commit?The listener transaction rolled back on the Kafka side, the record is redelivered, and the DB work is attempted again. Only the dedup table makes that safe
So are transactions worth it?For Kafka-to-Kafka pipelines (stream processing style) yes. For Kafka-to-database consumers the dedup table is the thing that gives you effectively-once, with or without Kafka transactions
Can I combine them with @RetryableTopic?No. The reference says non-blocking retries cannot be combined with container transactions
09
Chapter Nine - Hands-On

Walkthrough: One Poison Message, End to End

This chapter follows two bad records through the @RetryableTopic listener from chapter 7. Everything below comes from a real run of the lab project: Spring Boot 3.5.16, spring-kafka 3.3.16, an embedded KRaft broker, the listener shown earlier. Three records were sent to the topic payments: a good one (e-ok), one whose handler always throws IllegalStateException("card gateway unavailable") (e-flaky, a stand-in for a record that keeps failing), and one with quantity 0 that throws the excluded InvalidOrderException (e-invalid).

A poison message travelling through retry topics to the DLT (lab run)
payments main topic payments- retry-500 due after 500 ms payments- retry-1000 due after 1000 ms payments- retry-2000 due after 2000 ms payments- dlt dead letters fail fail fail fail attempt 1 attempt 2 attempt 3 attempt 4 excluded exception (InvalidOrderException): straight to the DLT, no retries Real timeline of message e-flaky (ms since test start): +10078 fails on main -> +10624 retry-500 (546 ms later) -> +11642 retry-1000 (1018 ms later) -> +13657 retry-2000 (2015 ms later) -> +14168 payments-dlt. The back-off doubled as configured. Message e-invalid arrived at +10631, went to the DLT at +11151 without waiting, and never blocked e-flaky.
Text - the listener trail, real output (+ms since the test started)
+10037ms payments attempt=null e-ok +10078ms payments attempt=null e-flaky +10624ms payments-retry-500 attempt=2 e-flaky +10631ms payments attempt=null e-invalid +11151ms payments-dlt original=payments org.springframework.kafka.listener.ListenerExecutionFailedException e-invalid +11642ms payments-retry-1000 attempt=3 e-flaky +13657ms payments-retry-2000 attempt=4 e-flaky +14168ms payments-dlt original=payments org.springframework.kafka.listener.ListenerExecutionFailedException e-flaky
e-ok is processed on payments
β–Ά
Nothing happens. The attempt header is null on a first delivery, exactly as the reference describes
e-flaky throws on the main topic
β–Ά
The framework publishes it to payments-retry-500 with a back-off timestamp and commits the main offset. The main consumer is free at once
e-invalid arrives while e-flaky waits
β–Ά
It is processed immediately (that is the ordering trade-off from chapter 7, shown live). It throws an excluded exception, the log says it will not be retried and sends it to payments-dlt directly
The retry containers pause until due
β–Ά
Attempts 2, 3 and 4 started about 0.5 s, 1 s and 2 s after the previous failure, matching delay = 500, multiplier = 2
Attempts are exhausted
β–Ά
After attempt 4 fails, e-flaky is published to payments-dlt, where the @DltHandler method receives it
Run it yourself

The lab is a normal Maven project (Boot parent 3.5.16, dependencies: spring-kafka, JDBC with H2, Actuator; test dependencies spring-kafka-test and Awaitility). The test below starts an in-process broker, so there is no Docker step. It is the whole test for this chapter:

Java - RetryTopicFlowTest from the lab
@SpringBootTest @ActiveProfiles("retry") @EmbeddedKafka(kraft = true, partitions = 1, topics = {"payments"}, bootstrapServersProperty = "spring.kafka.bootstrap-servers", brokerProperties = {"offsets.topic.replication.factor=1", "offsets.topic.num.partitions=3"}) class RetryTopicFlowTest { @Autowired KafkaTemplate<Object, Object> template; @Autowired EmbeddedKafkaBroker broker; @Autowired PaymentListener listener; @Test void retriesThenDlt() throws Exception { long t0 = System.currentTimeMillis(); template.send("payments", "p-1", new OrderPlaced("e-ok", "p-1", 5)).get(); template.send("payments", "p-2", new OrderPlaced("e-flaky", "p-2", 99)).get(); // always fails template.send("payments", "p-3", new OrderPlaced("e-invalid", "p-3", 0)).get(); // excluded: straight to DLT await().atMost(Duration.ofSeconds(60)).untilAsserted(() -> assertThat(listener.trail).anyMatch(s -> s.contains("payments-dlt") && s.endsWith("e-flaky")) .anyMatch(s -> s.contains("payments-dlt") && s.endsWith("e-invalid"))); System.out.println("=== TRAIL (in arrival order) ==="); listener.trail.forEach(System.out::println); System.out.println("elapsed ms: " + (System.currentTimeMillis() - t0)); try (AdminClient admin = AdminClient.create(java.util.Map.of("bootstrap.servers", broker.getBrokersAsString()))) { var names = new TreeSet<>(admin.listTopics().names().get()); names.removeIf(n -> n.startsWith("__")); System.out.println("=== TOPICS === " + names); } } }
  • $ mvn test -Dtest=RetryTopicFlowTest runs it. The first start is slow while the embedded KRaft broker boots.
  • The listener trail lives in a thread-safe list, so the test can await the final state instead of sleeping. That is the Awaitility pattern from chapter 10.
  • To watch the real topics in a longer-running setup, list them with $ bin/kafka-topics.sh --bootstrap-server localhost:9092 --list and consume payments-dlt with kafka-console-consumer.sh --property print.headers=true to see the headers.
Try changing
  • (1) Set attempts = "2" and watch the topic list shrink to one retry topic. (2) Remove the exclude and see e-invalid take the full retry route first. (3) Add a second consumer instance and note that retry topics have their own groups and partitions.
10
Chapter Ten - Quality

Testing and Observability

Messaging code fails in time, not in a stack frame, so the two skills here are the same skill: wait for an outcome (in tests) and watch the outcome over time (in production).

Testing with an embedded broker

spring-kafka-test provides @EmbeddedKafka, an in-JVM broker. Point Boot at it with bootstrapServersProperty:

Java - the annotation used in the lab tests
@SpringBootTest @ActiveProfiles("blocking") @EmbeddedKafka(kraft = true, partitions = 3, topics = {"orders", "orders-dlt"}, bootstrapServersProperty = "spring.kafka.bootstrap-servers", brokerProperties = {"offsets.topic.replication.factor=1", "offsets.topic.num.partitions=3"}) class BlockingFlowTest { // ... }
  • On spring-kafka 3.3 the embedded broker defaults to the ZooKeeper-based implementation; kraft = true (since 3.2) selects KRaft, which is what you want against Kafka 3.9. On spring-kafka 4.0 only KRaft remains and the ZooKeeper attributes are gone.
  • Create every topic your flow touches (orders-dlt, retry topics are created by the framework). The DLT needs at least the source topic's partition count when the default same-partition rule applies.
  • Use KafkaTestUtils (consumerProps, getRecords, getSingleRecord) to read what your code published. The lab reads the DLT with ByteArrayDeserializer to inspect raw bytes and headers.
Java - never Thread.sleep: await the state you expect (from BlockingFlowTest)
await().atMost(Duration.ofSeconds(90)).untilAsserted(() -> { assertThat(listener.seen).contains("e-1:reserved", "e-1:duplicate"); assertThat(dlt()).hasSize(2); });
@EmbeddedKafkaTestcontainers KafkaContainer
Needs DockerNoYes
Start timeIn-process, fast to startPulls and starts a container; reuse it across test classes
FidelityA real broker, but one node and test-oriented defaultsThe broker image you choose, closer to production (transactions, multi-listener setups)
Spring Boot wiringbootstrapServersProperty@ServiceConnection on the container field. Boot 3.5 supports KafkaContainer, ConfluentKafkaContainer and RedpandaContainer
Typical useMost listener and error-handling testsIntegration tests that must behave like the real cluster
Java - Testcontainers with @ServiceConnection (compiled with Testcontainers 1.21.4; not run here)
@SpringBootTest @Testcontainers class KafkaIT { @Container @ServiceConnection static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1")); @Test void contextLoads() { } }
Observability
SignalHow to get itWhat it tells you
Listener timer spring.kafka.listenerOn by default when Micrometer and a single MeterRegistry exist. Tags: container name, result (success or failure), exceptionProcessing time and failure ratio per listener
Template timer spring.kafka.templateSame conditions. Tags: template name, result, exceptionProducer-side latency and send failures
Kafka client metricsBoot auto-registers MicrometerConsumerListener and MicrometerProducerListenerNative client metrics such as the consumer records-lag-max
Observations and tracesspring.kafka.listener.observation-enabled=true, spring.kafka.template.observation-enabled=true plus a micrometer-tracing bridgeTrace context propagated through record headers, spans per send and per listener call
  • Turning observations on replaces the timers (the timers become part of each observation). Observations are not supported for batch listeners, which keep timers.
  • Tracing needs a bridge on the classpath (micrometer-tracing-bridge-otel or -brave) and an exporter, per the Spring Boot tracing page.
  • Consumer lag is the number to alert on. In Kafka it appears as records-lag-max in the consumer-fetch-manager-metrics group (Kafka 3.9 monitoring docs); the Micrometer binder exposes it under a dotted kafka.consumer.fetch.manager... name, so check your registry for the exact spelling. From outside the app, describe the group:
Shell - lag per partition from the command line
$ bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group inventory

The columns are GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID HOST CLIENT-ID. Output depends on your cluster, so none is pasted here. Rising LAG on all partitions means the group is too slow; on one partition it means a stuck or slow record (or a hot key).

11
Chapter Eleven - Pitfalls

Common Mistakes and What You See When They Happen

MistakeWhat you seeFix
concurrency greater than the partition countSome threads are idle (partitions assigned: [] in the logs), throughput does not improveAdd partitions (mind keyed topics) or speed up processing; chapter 3
Long processing in the listenerRepeating rebalances, CommitFailedException or "poll interval" warnings, records processed twiceLower max.poll.records, raise max.poll.interval.ms carefully (default 5 min), or hand work to a bounded executor and use manual acks
Deserialization failure without ErrorHandlingDeserializerThe same offset fails forever, CPU and logs explode, the partition never advancesWrap the deserializer (chapter 5) and route failures to a DLT
Wrong trusted.packagesA JsonDeserializer exception that names a class not in the trusted packagesList the package; avoid *
@KafkaListener(id=...) silently sets the groupA "new" group that starts at latest, or two listeners sharing offsets by accidentSet groupId explicitly
Retrying everythingBusiness errors (bad data) retried ten times, the partition stallsaddNotRetryableExceptions(...) or @RetryableTopic(exclude = ...)
DLT with fewer partitions than the sourceThe recoverer cannot publish to the same partition number, failures pile upCreate the DLT with at least as many partitions, or use a destination function that picks the partition
DLT template that JSON-encodes byte[]DLT payloads for malformed records are base64 text, the original bytes are goneDelegatingByTypeSerializer template (chapter 5)
DLT handler that throwsThe same record is re-published to the DLT repeatedly under ALWAYS_RETRY_ON_ERRORKeep handlers trivial, use optional headers, or FAIL_ON_ERROR
Using @RetryableTopic and needing orderRecords for one key processed out of order after a failureUse blocking retries for ordered streams, or design handlers to tolerate reordering
Non-unique transaction-id-prefix across instancesProducer fenced or transaction errors after scaling outMake the prefix unique per instance
Sending in a loop with get()Low producer throughput, many tiny batchesSend async, await a list of futures, or rely on callbacks
⚠ Treating retries as a cure for poison
  • Retrying a record that can never succeed only delays the DLT. Decide per exception type: fatal (bad data, deserialization, validation) goes straight to the DLT; transient (timeouts, brief outages) gets back-off; unknown gets a small bounded number of attempts and a DLT at the end. Then make someone own the DLT.
12
Chapter Twelve - Interview

Questions You Should Be Able to Answer

Crisp answers for the questions that come up in interviews on this topic.

  • Q: What does Spring Kafka add over the plain Kafka clients?
    A thread-safe KafkaTemplate, a listener container that owns the poll loop, threading, offset commits, rebalance handling and error handling, @KafkaListener annotations, JSON serializers, retry topics, transactions integration and Micrometer hooks.
  • Q: How does concurrency relate to partitions?
    Each unit of concurrency is one consumer thread in the group. Partitions are assigned to threads, so threads beyond the partition count stay idle. Total consumers across all app instances should not exceed the partition count.
  • Q: What is an ack mode and which is the default?
    It controls when the container commits offsets. BATCH is the default (commit after all records of a poll were processed). RECORD commits per record; MANUAL and MANUAL_IMMEDIATE wait for Acknowledgment.acknowledge().
  • Q: Why can a listener see the same record twice even with correct code?
    Delivery is at-least-once: the commit follows processing, so a crash, a rebalance or a retry between the two replays the record. Make the handler idempotent with a dedup table in the same transaction as the effect.
  • Q: What happens when a listener throws with the default configuration?
    DefaultErrorHandler redelivers the record with FixedBackOff(0, 9) (ten deliveries), then logs it at ERROR and skips it. The partition is blocked meanwhile.
  • Q: How do you send failed records to a dead-letter topic?
    Use a DefaultErrorHandler with a DeadLetterPublishingRecoverer. By default it publishes to <topic>-dlt on the same partition, with headers describing the exception and the original topic, partition, offset and group.
  • Q: Which exceptions are not retried by default?
    DeserializationException, MessageConversionException, ConversionException, MethodArgumentResolutionException, NoSuchMethodException and ClassCastException go to the recoverer on the first failure. You can add more.
  • Q: What is ErrorHandlingDeserializer for?
    A deserializer failure happens inside poll() and would loop on the same record forever. The wrapper catches it, returns null plus a DeserializationException header with the raw bytes, and the container routes the record to the error handler.
  • Q: Blocking versus non-blocking retries?
    Blocking retries redeliver the same record in place and keep order but stall the partition. @RetryableTopic publishes to retry topics with delays and continues, so throughput holds but ordering is lost; it is not supported with batch listeners or container transactions.
  • Q: How many topics does @RetryableTopic(attempts = "4") create?
    Three retry topics plus the DLT (attempts counts the first try). Names carry the delay (payments-retry-500, -1000, -2000) or an index for fixed back-offs, plus the -dlt topic.
  • Q: How do you get exactly-once with Spring Kafka?
    Inside Kafka: set transaction-id-prefix, use read_committed consumers; Boot wires a KafkaTransactionManager so consumed offsets and produced records commit atomically. For external systems you need an idempotent consumer, or an outbox on the producing side.
  • Q: Why is transaction-id-prefix required to be unique per instance?
    The transactional.id is prefix plus a counter. Two instances sharing it would fence each other. Spring Kafka 3.x supports only EOSMode.V2, which needs uniqueness per instance rather than per partition.
  • Q: How do you test a Kafka listener?
    With @EmbeddedKafka (use kraft = true on spring-kafka 3.3) or a Testcontainers KafkaContainer with @ServiceConnection, and Awaitility to wait for the effect instead of sleeping.
  • Q: Which metrics would you alert on?
    Consumer lag (records-lag-max), listener failure ratio from spring.kafka.listener, DLT growth, rebalance frequency, and producer send failures from spring.kafka.template.
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
TopicRememberDefault (spring-kafka 3.3 / Kafka 3.9)
Dependencyspring-kafka only; Boot manages versionsBoot 3.5.16 -> spring-kafka 3.3.16, kafka-clients 3.9.2
Sendtemplate.send(topic, key, value) returns CompletableFutureIdempotent producer, delivery.timeout.ms 2 min
Group id@KafkaListener(id) overrides the factory groupSet groupId explicitly
ConcurrencyThreads per listener, capped by partitionsFactory value, 1 unless set
Ack modeWhen offsets are committedBATCH; enable.auto.commit forced false
Poll budgetAll processing between two pollsmax.poll.interval.ms 5 min, max.poll.records 500
JSONTrust packages, wrap in ErrorHandlingDeserializerTrusted: java.util, java.lang
Blocking retryDefaultErrorHandler + BackOffFixedBackOff(0, 9), then log and skip
DLTDeadLetterPublishingRecoverer<topic>-dlt, same partition, kafka_dlt-* headers
Retry topics@RetryableTopic: non-blocking, unordered3 attempts, 1 s fixed, -retry-N and -dlt
IdempotencyDedup row in the same DB transactionYour code; keep rows beyond the redelivery window
Transactionstransaction-id-prefix, read_committedPrefix unique per instance
Tests@EmbeddedKafka(kraft = true) or Testcontainers, AwaitilityZooKeeper embedded broker is the 3.3 default
Observabilityspring.kafka.*.observation-enabled, lag metricObservation flags default false
βˆ‘ Kafka with Spring - what to remember
  • The container is the poll loop. Your listener runs on its thread; slow code delays the next poll and can trigger a rebalance.
  • Concurrency never exceeds partitions. Size partitions first.
  • Commits follow processing, so delivery is at-least-once. Make handlers idempotent with a dedup table in the same transaction.
  • **Wrap deserializers in ErrorHandlingDeserializer** and keep trusted.packages narrow.
  • Classify failures: fatal goes to the DLT, transient gets back-off, and somebody watches the DLT.
  • **@RetryableTopic trades ordering for throughput** and cannot be used with batch listeners or container transactions.
  • Kafka transactions end at Kafka. Databases need dedup or an outbox (page 07).
  • Test by awaiting outcomes, and alert on consumer lag.
Version note

Verified on 2026-10-31 against: the Spring for Apache Kafka 3.3 reference (sending, listener container, annotations, error handling, serdes, non-blocking retries, transactions, testing, monitoring), the Spring Boot 3.5 application-properties appendix, Kafka messaging, Actuator metrics and Testcontainers pages, the Kafka 3.9 consumer and producer configuration pages, and the Boot dependency POMs (Boot 3.5.16 -> spring-kafka 3.3.16, kafka-clients 3.9.2; Boot 4.0.0 -> 4.0.0 / 4.1.1; Boot 4.1.0 -> 4.1.0 / 4.2.1). All Java code was compiled against Boot 3.5.16, and the dead-letter, retry-topic and dedup outputs came from real runs on an embedded KRaft broker. Not run: the Testcontainers test, transactional listeners, the manual-ack and batch listeners (compiled only). Exact Micrometer meter names for client lag were not verified. For Boot 4.x and spring-kafka 4.x re-check package names and the change history first.

Next in this section