Kafka with Spring Boot: Complete Beginner to Production Guide
Master Apache Kafka with Spring Boot — KafkaTemplate, @KafkaListener, consumer groups, partitions, JsonSerializer, and lag monitoring in production..
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Use
KafkaTemplate.send(topic, key, value)to produce messages from any Spring bean - Annotate methods with
@KafkaListener(topics="orders", groupId="order-service")to consume - Register a
NewTopic@Bean to auto-create topics with partition/replication config - Configure
JsonSerializer/JsonDeserializerfor automatic POJO serialization - Monitor consumer lag with
kafka-consumer-groups.sh --describeto detect processing backlogs
Think of Kafka like a massive, ordered logbook in a busy post office. Producers drop letters (messages) into named mailboxes (topics), which are split into numbered slots (partitions) for parallel delivery. Consumer groups are teams of postal workers — each worker handles a different slot, and Kafka remembers exactly which letter each team last processed, so nothing gets lost or delivered twice.
Every high-traffic production system eventually hits the wall: the monolith that tries to do everything synchronously collapses under load. An order placed on Black Friday triggers an inventory check, a fraud scan, an email, a push notification, and a loyalty-points calculation — all in one HTTP request. The timeout cascades. Users see errors. The on-call engineer gets paged at 2 AM.
Apache Kafka solves this by decoupling producers from consumers through a distributed, durable, ordered log. Instead of calling seven downstream services synchronously, you write one message to a Kafka topic and go back to serving the next request. Each downstream service consumes at its own pace, retries independently, and scales horizontally without touching the producer.
Spring Boot's spring-kafka autoconfiguration makes wiring Kafka into a production service remarkably straightforward. A KafkaTemplate bean is ready to inject the moment you add the dependency and configure spring.kafka.bootstrap-servers. An @KafkaListener annotation turns any method into a message handler with full Spring context — transactions, security, metrics — all available.
But the ease of getting started hides the operational complexity lurking underneath. Consumer group rebalancing can pause processing for seconds. Poorly chosen partition counts bottleneck throughput. A single slow consumer can cause the entire group to fall behind, piling up unbounded lag. Without proper observability — consumer lag dashboards, offset monitoring, dead-letter queues — you won't know you have a problem until your database is backed up by hours of unprocessed events.
This guide walks through every layer: the Spring Boot autoconfiguration that bootstraps the connection, producing and consuming type-safe POJOs with JSON serialization, managing topics programmatically, and monitoring consumer lag in production using real Kafka CLI commands. Every concept is grounded in production patterns used at scale.
Setting Up Spring Boot with Kafka: Autoconfiguration Deep Dive
Spring Boot's autoconfiguration for Kafka activates the moment spring-kafka is on the classpath and spring.kafka.bootstrap-servers is set. Behind the scenes, KafkaAutoConfiguration creates a KafkaAdmin bean (for topic management), a KafkaTemplate bean (for producing), and a ConcurrentKafkaListenerContainerFactory bean (for consuming). You rarely need to declare these manually.
The producer factory uses spring.kafka.producer.* properties to build the underlying KafkaProducer. Key properties: key-serializer defaults to StringSerializer, value-serializer should be set to JsonSerializer for POJO payloads. The consumer factory mirrors this with key-deserializer and value-deserializer. Spring Boot maps spring.kafka.consumer.group-id directly to the Kafka group.id property, so all @KafkaListener methods in the application share the same group unless overridden.
For multi-environment setups, externalise all Kafka config in application.yml and use Spring profiles. In local development, a Testcontainers KafkaContainer or an embedded broker (@EmbeddedKafka from spring-kafka-test) provides a realistic test environment without a running cluster. In production, point bootstrap-servers at your MSK or Confluent endpoint and layer in SSL/SASL configuration via spring.kafka.ssl.* and spring.kafka.security.protocol.
One subtle autoconfiguration detail: KafkaAdmin.autoCreateTopics defaults to true, meaning any NewTopic @Bean you declare will be created automatically on startup if it doesn't exist, and the partition/replication configuration will be applied to new topics but NOT retroactively updated on existing ones. This is a common source of confusion when changing partition counts — Kafka doesn't support reducing partitions, and the NewTopic bean won't add partitions to an existing under-partitioned topic without explicit intervention via the AdminClient API.
orders topic already exists with 3 partitions and you change the NewTopic bean to 6 partitions, Spring Boot will NOT add partitions automatically. You must use kafka-topics.sh --alter or the AdminClient API. Never rely on NewTopic beans for partition changes on live topics.auto-offset-reset: earliest for new consumer groups so they process historical messages from the beginning. Use latest only for real-time-only consumers where historical data has no value. Misconfiguring this causes new deployments to silently skip months of messages.Producing Messages with KafkaTemplate
The KafkaTemplate<K, V> is Spring Kafka's primary API for sending messages. It wraps the native KafkaProducer and adds Spring-friendly abstractions: a default topic, header management, and a CompletableFuture<SendResult<K, V>> return type for async result handling.
The most important overload in production is send(String topic, K key, V value). The key is critical: Kafka routes all messages with the same key to the same partition, guaranteeing order for that key. For an order service, use orderId as the key — all events for a given order (created, paid, shipped) land on the same partition and are processed in sequence by the same consumer.
The returned CompletableFuture resolves when the broker acknowledges the message according to the acks setting. With acks=all (the production default), resolution means all in-sync replicas have persisted the message — durable and safe. Never call .send() and discard the future in production code; at minimum, attach a callback to log send failures. Better yet, wire a global ProducerListener bean that records metrics on every success and failure.
For high-throughput scenarios, KafkaTemplate supports batching transparently via the producer's batch.size and linger.ms settings. Setting linger.ms=5 causes the producer to wait up to 5ms before flushing a batch, dramatically increasing throughput at the cost of slightly higher latency. In microservices with bursty traffic patterns, this trade-off is almost always worth it. Enable compression.type=snappy or lz4 alongside batching to reduce network and broker storage overhead by 60-70% for JSON payloads.
Transactional producers require a transactionIdPrefix on the ProducerFactory and a KafkaTransactionManager bean. Inside a @Transactional method, all KafkaTemplate.send() calls participate in a single atomic transaction — either all messages are committed or none are, even across multiple topics. This is the foundation for exactly-once semantics when combined with isolation.level=read_committed on consumers.
kafkaTemplate.send(topic, key, value) without attaching a callback or calling .get() means producer errors (broker unreachable, topic not found, serialization failure) are silently discarded. In production, every send future must have a whenComplete callback that at minimum logs the failure and increments an error metric.ProducerInterceptor that adds a unique message ID header. This enables idempotent consumers to detect and skip duplicates without relying solely on Kafka's idempotent producer setting.Consuming Messages with @KafkaListener
The @KafkaListener annotation transforms a Spring-managed method into a Kafka message handler. The listener container factory polls the broker on a background thread, deserializes the record, and invokes your method. Spring's full DI context is active — you can inject repositories, call other services, and participate in transactions exactly as you would in a REST controller.
The method signature is flexible. Accept ConsumerRecord<K, V> for access to offset, partition, headers, and timestamp. Accept just the value type for clean domain code. Inject @Header(KafkaHeaders.RECEIVED_PARTITION) or @Header(KafkaHeaders.OFFSET) for individual header values. Accept Acknowledgment ack when using AckMode.MANUAL to control exactly when the offset is committed.
The concurrency attribute on @KafkaListener (or on the container factory) controls how many consumer threads the application runs. With concurrency=3 on a topic with 6 partitions, Spring starts 3 KafkaMessageListenerContainer instances, each consuming 2 partitions. Setting concurrency higher than the partition count wastes threads — the excess consumers sit idle waiting for a rebalance that gives them a partition. A common production pattern is to set partition count equal to the maximum expected consumer concurrency.
Partition assignment and group coordination happen automatically, but you can influence behavior with @TopicPartition on @KafkaListener for static partition assignment — useful for consumers that need to maintain local state (e.g., windowed aggregations) without rebalance disruption. Static assignment bypasses the group coordinator and requires careful management when scaling, so use it judiciously.
Error handling in @KafkaListener follows a layered model: the method itself can throw, triggering the container's error handler. The default DefaultErrorHandler retries with exponential backoff and then sends the record to a dead-letter topic (if configured). Understanding this pipeline is essential — an unconfigured error handler means a poison message will retry infinitely, blocking the partition.
concurrency=10 on a topic with 3 partitions starts 10 threads but only 3 will ever receive messages. The other 7 consume memory and CPU for zero throughput benefit. Always align concurrency with your topic's partition count.groupId values for different business purposes consuming the same topic (e.g., order-service for fulfillment, analytics-service for reporting). Each group maintains independent offsets and scales independently — this is Kafka's broadcast pattern.