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.
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.
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.
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 Boot | spring-kafka | kafka-clients | Notes |
|---|---|---|---|
| 3.5.16 (latest 3.5.x checked) | 3.3.16 | 3.9.2 | This page. The code was compiled and run against exactly this set |
| 3.5.14 | 3.3.15 | 3.9.2 | Same line, earlier patch |
| 4.0.0 | 4.0.0 | 4.1.1 | Next major line. Same concepts, some packages and test classes changed (see the version note) |
| 4.1.0 | 4.1.0 | 4.2.1 | Spring 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).
- The Boot parent (or the
spring-boot-dependenciesBOM) managesspring-kafka,spring-kafka-testandkafka-clients. Pinningkafka-clientsyourself is the usual way to end up with a client Spring Kafka was not tested against.
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.*.
| Property | What it controls | Notes |
|---|---|---|
spring.kafka.bootstrap-servers | Initial broker list for all clients | Comma-separated host:port. Can be overridden per client type |
spring.kafka.consumer.group-id | Default consumer group | A @KafkaListener(id = "x") overrides it (chapter 3) |
spring.kafka.consumer.auto-offset-reset | Where a new group starts | Kafka default is latest; the lab uses earliest |
spring.kafka.consumer.enable-auto-commit | Kafka-side periodic commit | The container sets it to false unless you set it explicitly. Leave it alone |
spring.kafka.consumer.max-poll-records, max-poll-interval | Batch size and the allowed time between polls | Kafka defaults 500 and 5 minutes (checked on the Kafka 3.9 consumer config page) |
spring.kafka.consumer.isolation-level | Read committed transactional data or not | Kafka default read_uncommitted |
spring.kafka.consumer.key-deserializer, value-deserializer | Deserializer classes | Use ErrorHandlingDeserializer as the outer one (chapter 5) |
spring.kafka.producer.key-serializer, value-serializer | Serializer classes | Often StringSerializer and JsonSerializer |
spring.kafka.producer.acks, retries, batch-size, compression-type | Typed producer settings | Idempotence is on by default in Kafka 3.x clients when settings do not conflict |
spring.kafka.producer.transaction-id-prefix | Turns on producer transactions | Also auto-configures a KafkaTransactionManager (chapter 8) |
spring.kafka.listener.ack-mode | When the container commits offsets | Container default is BATCH (chapter 4) |
spring.kafka.listener.concurrency | Consumer threads per listener | Capped by partition count (chapter 3) |
spring.kafka.listener.type | single or batch | Default single |
spring.kafka.listener.poll-timeout | Timeout of each poll() call | Container default is 5 seconds |
spring.kafka.listener.observation-enabled, spring.kafka.template.observation-enabled | Micrometer observations and traces | Default false (chapter 10) |
spring.kafka.retry.topic.enabled, attempts, backoff.* | Global non-blocking retry config | enabled default false, attempts default 3, backoff.delay default 1s (chapter 7) |
spring.kafka.template.default-topic | Topic for sendDefault(...) |
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.
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:
- 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
NewTopicbeans for local and test setups.
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.
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.
- The key decides the partition, hence the ordering scope. Use the entity id (
orderId) for everything that must stay ordered per entity. Anullkey 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
ProducerRecordor build aMessagewithMessageBuilder.
| Question | Answer |
|---|---|
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) |
- A bare
template.send(...)whose result nobody looks at hides failures: a broker outage,RecordTooLargeExceptionor serialization error turns into a lost event with nothing in the logs of your own code. Always attachwhenComplete, aProducerListener, orget(). For events that must reach Kafka whenever a database write succeeded, use the transactional outbox (page 07), not a clever callback.
@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.
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 attribute | Meaning | Gotcha |
|---|---|---|
id | Container id, and the consumer group id | Since Spring Kafka 2.0 id overrides the group id from the consumer factory. Set groupId explicitly, or idIsGroup = false, if you want something else |
groupId | Explicit consumer group | Two listeners with the same group on one topic share its partitions; different groups each get every record |
topics, topicPattern, topicPartitions | What to subscribe to | Explicit topicPartitions skip group management: no rebalances, no failover |
concurrency | Threads for this listener | Overrides the factory value. Capped by partitions in practice |
containerFactory | Which factory builds the container | Use a second factory for a different ack mode or a batch listener |
batch | Receive a List per poll | Overrides the factory batchListener flag (since 2.8) |
properties | Consumer properties for just this listener | group.id and client.id cannot be set this way |
autoStartup | Start with the context or later | Start it through KafkaListenerEndpointRegistry |
- Each thread polls its own partitions, so ordering inside a partition is preserved: one thread, one partition, records in order.
- With
concurrency = 3on a 6-partition topic each thread owns two partitions. Withconcurrency = 8two 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
RangeAssignorcan leave threads idle even when there are enough partitions overall; the reference suggests aRoundRobinAssignorthroughpartition.assignment.strategyfor that case.
- Choose partitions first (page 02: from target throughput and the slower side), then set
concurrencyto partitions divided by the number of instances, rounded up.
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.
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).
| AckMode | Offset is committed... | If the app crashes mid-way | Use when |
|---|---|---|---|
RECORD | after the listener returns for each record | At most one record is reprocessed | Per-record safety matters more than commit overhead |
BATCH (default) | after all records of one poll() were processed | Up to max.poll.records (default 500) records are reprocessed | High throughput, idempotent handlers |
TIME, COUNT, COUNT_TIME | after a batch, once ackTime and/or ackCount has passed | More reprocessing, fewer commits | Commit cost is a real bottleneck |
MANUAL | after you call ack.acknowledge(), with BATCH semantics | Records not acknowledged are redelivered | Commit only after an async step finished |
MANUAL_IMMEDIATE | right when acknowledge() is called | Same, with the commit sent at once | You 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.
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.
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).
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).
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.
| Concern | Property (Spring Kafka 3.3) | Default / note |
|---|---|---|
Add __TypeId__ header when producing | spring.json.add.type.headers | true. Set false when consumers should not depend on your class names |
| Fallback type when no header | spring.json.value.default.type | Required when you turn type headers off |
| Ignore the type header when consuming | spring.json.use.type.headers | true |
| Allowed packages for deserialization | spring.json.trusted.packages | java.util, java.lang only. * allows everything |
| Map logical names to classes | spring.json.type.mapping on both sides | Example: order:com.shop.OrderPlaced. Avoids sharing class names across services |
| Wrap a failing deserializer | Outer ErrorHandlingDeserializer plus spring.deserializer.value.delegate.class | See below |
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:
- 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.
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.
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).
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:
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.
- 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-serializerandvalue-deserializerproperties.
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.
| Piece | What it does | Default (Spring Kafka 3.3 reference) |
|---|---|---|
BackOff | Delay and number of redeliveries | FixedBackOff(0L, 9): no delay, 10 deliveries in total |
| Recoverer | What happens when attempts are exhausted | Log at ERROR and skip. Use DeadLetterPublishingRecoverer to publish instead |
| Not-retryable exceptions | Skip retries, call the recoverer on the first failure | DeserializationException, MessageConversionException, ConversionException, MethodArgumentResolutionException, NoSuchMethodException, ClassCastException. Add your own with addNotRetryableExceptions(...) |
seekAfterError | Re-seek vs keep records in memory while backing off | true. Set false to resubmit the already polled records without seeking |
| Batch listeners | Throw BatchListenerFailedException(msg, index) | Any other exception retries the whole batch |
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:
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.
- 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 thanmax.poll.interval.ms. If you need minutes or hours between attempts, do not block: use retry topics (chapter 7).
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):
- 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-0andorders-dlt-1: the same partition numbers as inorders(keyso-2ando-3hash 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.
- 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.
@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.
- 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
@DltHandlermethod consumes it. - The framework creates the topics (through
KafkaAdmin) and the listener containers for you.
| @RetryableTopic attribute | Default | Meaning |
|---|---|---|
attempts | 3 | Total tries including the first one: 4 means one original plus three retries |
backoff | @Backoff 1000 ms fixed | Spring Retry @Backoff (delay, multiplier, maxDelay, random) |
include / exclude | retry all | exclude sends the exception straight to the DLT. Add traversingCauses = "true" to look at nested causes |
kafkaTemplate | bean defaultRetryTopicKafkaTemplate | The 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, -retry | Topic naming |
dltStrategy | ALWAYS_RETRY_ON_ERROR | What happens if the DLT handler itself throws (see the trap below). Other values: FAIL_ON_ERROR, NO_DLT |
numPartitions, replicationFactor, autoCreateTopics | 1, -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 |
concurrency | same as main container | Since 3.0 you can set different concurrency for retry containers |
timeout | none | Global limit for the whole retry process |
With a main topic payments, attempts = 4 and exponential back-off from 500 ms the framework created exactly these topics in the lab:
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.
| Gain | Price |
|---|---|
| One bad or temporarily failing record no longer blocks the partition | Ordering 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 busy | More 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 payload | Not supported with batch listeners and cannot be combined with container transactions |
- 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 plainDeadLetterPublishingRecoverer(kafka_dlt-exception-fqcn, ...). The handler failed withMissing header, and with the defaultALWAYS_RETRY_ON_ERRORthe record was published to the DLT again and again, filling the log in under a minute. UseKafkaHeaders.EXCEPTION_FQCNandORIGINAL_TOPICin this flow, mark optional headersrequired = false, and keep DLT handlers trivially safe.
- Add
@Header(name = RetryTopicHeaders.DEFAULT_HEADER_ATTEMPTS, required = false) Integer attemptto the listener. It isnullon the first delivery.KafkaMessageHeaderAccessor(since 3.0.10) returns 1 for the initial delivery and also exposes blocking attempts.
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:
The listener calls it and logs the outcome. The lab sent the same event e-1 twice. This is the real log:
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.sendinside 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 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.
- Setting
transaction-id-prefixmakes 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 onlyEOSMode.V2, which no longer needs the same id across restarts). - Consumers see aborted records unless they set
isolation.level=read_committed(Kafka default isread_uncommitted). - Outside a listener,
@Transactionalmethods andtemplate.executeInTransaction(...)start a producer-initiated transaction.
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.
| Question | Honest 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 |
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).
e-ok is processed on paymentsattempt header is null on a first delivery, exactly as the reference describese-flaky throws on the main topicpayments-retry-500 with a back-off timestamp and commits the main offset. The main consumer is free at oncee-invalid arrives while e-flaky waitspayments-dlt directlydelay = 500, multiplier = 2e-flaky is published to payments-dlt, where the @DltHandler method receives itThe 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:
$ mvn test -Dtest=RetryTopicFlowTestruns 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 --listand consumepayments-dltwithkafka-console-consumer.sh --property print.headers=trueto see the headers.
- (1) Set
attempts = "2"and watch the topic list shrink to one retry topic. (2) Remove theexcludeand seee-invalidtake the full retry route first. (3) Add a second consumer instance and note that retry topics have their own groups and partitions.
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).
spring-kafka-test provides @EmbeddedKafka, an in-JVM broker. Point Boot at it with bootstrapServersProperty:
- 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 withByteArrayDeserializerto inspect raw bytes and headers.
| @EmbeddedKafka | Testcontainers KafkaContainer | |
|---|---|---|
| Needs Docker | No | Yes |
| Start time | In-process, fast to start | Pulls and starts a container; reuse it across test classes |
| Fidelity | A real broker, but one node and test-oriented defaults | The broker image you choose, closer to production (transactions, multi-listener setups) |
| Spring Boot wiring | bootstrapServersProperty | @ServiceConnection on the container field. Boot 3.5 supports KafkaContainer, ConfluentKafkaContainer and RedpandaContainer |
| Typical use | Most listener and error-handling tests | Integration tests that must behave like the real cluster |
| Signal | How to get it | What it tells you |
|---|---|---|
Listener timer spring.kafka.listener | On by default when Micrometer and a single MeterRegistry exist. Tags: container name, result (success or failure), exception | Processing time and failure ratio per listener |
Template timer spring.kafka.template | Same conditions. Tags: template name, result, exception | Producer-side latency and send failures |
| Kafka client metrics | Boot auto-registers MicrometerConsumerListener and MicrometerProducerListener | Native client metrics such as the consumer records-lag-max |
| Observations and traces | spring.kafka.listener.observation-enabled=true, spring.kafka.template.observation-enabled=true plus a micrometer-tracing bridge | Trace 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-otelor-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-maxin theconsumer-fetch-manager-metricsgroup (Kafka 3.9 monitoring docs); the Micrometer binder exposes it under a dottedkafka.consumer.fetch.manager...name, so check your registry for the exact spelling. From outside the app, describe the group:
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).
Common Mistakes and What You See When They Happen
| Mistake | What you see | Fix |
|---|---|---|
concurrency greater than the partition count | Some threads are idle (partitions assigned: [] in the logs), throughput does not improve | Add partitions (mind keyed topics) or speed up processing; chapter 3 |
| Long processing in the listener | Repeating rebalances, CommitFailedException or "poll interval" warnings, records processed twice | Lower 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 ErrorHandlingDeserializer | The same offset fails forever, CPU and logs explode, the partition never advances | Wrap the deserializer (chapter 5) and route failures to a DLT |
Wrong trusted.packages | A JsonDeserializer exception that names a class not in the trusted packages | List the package; avoid * |
@KafkaListener(id=...) silently sets the group | A "new" group that starts at latest, or two listeners sharing offsets by accident | Set groupId explicitly |
| Retrying everything | Business errors (bad data) retried ten times, the partition stalls | addNotRetryableExceptions(...) or @RetryableTopic(exclude = ...) |
| DLT with fewer partitions than the source | The recoverer cannot publish to the same partition number, failures pile up | Create 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 gone | DelegatingByTypeSerializer template (chapter 5) |
| DLT handler that throws | The same record is re-published to the DLT repeatedly under ALWAYS_RETRY_ON_ERROR | Keep handlers trivial, use optional headers, or FAIL_ON_ERROR |
Using @RetryableTopic and needing order | Records for one key processed out of order after a failure | Use blocking retries for ordered streams, or design handlers to tolerate reordering |
Non-unique transaction-id-prefix across instances | Producer fenced or transaction errors after scaling out | Make the prefix unique per instance |
Sending in a loop with get() | Low producer throughput, many tiny batches | Send async, await a list of futures, or rely on callbacks |
- 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.
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-safeKafkaTemplate, a listener container that owns the poll loop, threading, offset commits, rebalance handling and error handling,@KafkaListenerannotations, JSON serializers, retry topics, transactions integration and Micrometer hooks. - Q: How does
concurrencyrelate 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.BATCHis the default (commit after all records of a poll were processed).RECORDcommits per record;MANUALandMANUAL_IMMEDIATEwait forAcknowledgment.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?
DefaultErrorHandlerredelivers the record withFixedBackOff(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 aDefaultErrorHandlerwith aDeadLetterPublishingRecoverer. By default it publishes to<topic>-dlton 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,NoSuchMethodExceptionandClassCastExceptiongo to the recoverer on the first failure. You can add more. - Q: What is
ErrorHandlingDeserializerfor?
A deserializer failure happens insidepoll()and would loop on the same record forever. The wrapper catches it, returnsnullplus aDeserializationExceptionheader 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.@RetryableTopicpublishes 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 (attemptscounts the first try). Names carry the delay (payments-retry-500,-1000,-2000) or an index for fixed back-offs, plus the-dlttopic. - Q: How do you get exactly-once with Spring Kafka?
Inside Kafka: settransaction-id-prefix, useread_committedconsumers; Boot wires aKafkaTransactionManagerso 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-prefixrequired to be unique per instance?
Thetransactional.idis prefix plus a counter. Two instances sharing it would fence each other. Spring Kafka 3.x supports onlyEOSMode.V2, which needs uniqueness per instance rather than per partition. - Q: How do you test a Kafka listener?
With@EmbeddedKafka(usekraft = trueon spring-kafka 3.3) or a TestcontainersKafkaContainerwith@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 fromspring.kafka.listener, DLT growth, rebalance frequency, and producer send failures fromspring.kafka.template.
Cheat Sheet and Recap
| Topic | Remember | Default (spring-kafka 3.3 / Kafka 3.9) |
|---|---|---|
| Dependency | spring-kafka only; Boot manages versions | Boot 3.5.16 -> spring-kafka 3.3.16, kafka-clients 3.9.2 |
| Send | template.send(topic, key, value) returns CompletableFuture | Idempotent producer, delivery.timeout.ms 2 min |
| Group id | @KafkaListener(id) overrides the factory group | Set groupId explicitly |
| Concurrency | Threads per listener, capped by partitions | Factory value, 1 unless set |
| Ack mode | When offsets are committed | BATCH; enable.auto.commit forced false |
| Poll budget | All processing between two polls | max.poll.interval.ms 5 min, max.poll.records 500 |
| JSON | Trust packages, wrap in ErrorHandlingDeserializer | Trusted: java.util, java.lang |
| Blocking retry | DefaultErrorHandler + BackOff | FixedBackOff(0, 9), then log and skip |
| DLT | DeadLetterPublishingRecoverer | <topic>-dlt, same partition, kafka_dlt-* headers |
| Retry topics | @RetryableTopic: non-blocking, unordered | 3 attempts, 1 s fixed, -retry-N and -dlt |
| Idempotency | Dedup row in the same DB transaction | Your code; keep rows beyond the redelivery window |
| Transactions | transaction-id-prefix, read_committed | Prefix unique per instance |
| Tests | @EmbeddedKafka(kraft = true) or Testcontainers, Awaitility | ZooKeeper embedded broker is the 3.3 default |
| Observability | spring.kafka.*.observation-enabled, lag metric | Observation flags default false |
- 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 keeptrusted.packagesnarrow. - Classify failures: fatal goes to the DLT, transient gets back-off, and somebody watches the DLT.
- **
@RetryableTopictrades 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.
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.
- 05 RabbitMQ - the same ideas (retries, dead letters, idempotency) on a broker with per-message acknowledgments.
- 07 Event-Driven Patterns - the transactional outbox, sagas and idempotent consumers in depth.
- 03 Kafka Producers & Consumers - the plain client behaviour under this container.
- Spring: Transactions - the transaction managers the chapter 8 setup relies on.