Kafka Explained: How It Works and Why It Matters
Modern systems rarely live in isolation. An order service may need to notify a payment service, update inventory, trigger analytics, send a confirmation email, and inform a fraud-detection system as part of a single business flow.
The simplest way to connect services is synchronous communication.
flowchart TD
order["Order Service"]
payment["Payment Service"]
order -->|" HTTP "| payment
That works well when the caller needs an immediate response. But not every interaction needs to happen immediately, and not every downstream dependency should be able to affect the original request.
If the payment service is temporarily unavailable, should the order service fail too? If analytics processing takes a few seconds, should the user wait for it? Questions like these led distributed systems toward asynchronous messaging.
From Direct Calls to Messaging
Instead of one service directly calling another, the producer can publish a message and continue processing.
flowchart TD
order["Order Service"]
payment["Payment Service"]
order -->|" Message Queue "| payment
Message queues solved several important problems. Producers and consumers became less tightly coupled, consumers could process work at their own pace, and temporary failures no longer had to immediately affect the producer.
For many systems, this model is still perfectly adequate. But as distributed architectures became larger and more event-driven, engineers started asking for more than simple message delivery.
What if several independent systems need the same event? What if we need to process yesterday’s events again? What if ordering matters? What if we need to process a very high volume of events while scaling consumers horizontally?
This is where Apache Kafka becomes particularly interesting.
A Different Mental Model
Kafka is often introduced as a messaging system. That description is not wrong, but it can lead to the wrong mental model.
A more useful way to think about Kafka is as a distributed, durable log of events.
Producers append events to that log, Kafka stores them for a configured period of time, and consumers independently move through the log at their own pace. Once this mental model makes sense, concepts such as partitions, offsets, and consumer groups become much easier to understand.
The Basic Kafka Architecture
At a high level, Kafka consists of a few important building blocks:
- Producer — publishes events
- Topic — a logical stream of related events
- Partition — an ordered section of a topic
- Broker — a Kafka server that stores partitions
- Consumer — reads events
- Consumer Group — multiple consumers working together
- Offset — the position of an event inside a partition
A simplified Kafka setup looks like this:
flowchart LR
producer["Producer"]
subgraph kafka["Kafka Cluster"]
direction LR
broker1["Broker 1<br/>Partition P0"]
broker2["Broker 2<br/>Partition P1"]
broker3["Broker 3<br/>Partition P2"]
end
consumerA["Consumer A"]
consumerB["Consumer B"]
consumerC["Consumer C"]
producer -->|" Publish "| broker1
producer -->|" Publish "| broker2
producer -->|" Publish "| broker3
broker1 -->|" Read "| consumerA
broker2 -->|" Read "| consumerB
broker3 -->|" Read "| consumerC
The important detail here is that Kafka is normally a cluster of brokers rather than one central server. Topics are split into partitions, and those partitions are distributed across the cluster.
What Exactly Is a Kafka Broker?
A broker is a Kafka server participating in a Kafka cluster. A production Kafka cluster usually contains multiple brokers so that storage and traffic can be distributed across several machines.
Imagine a topic called order-events with three partitions:
flowchart TB
topic["Topic: order-events"]
subgraph cluster["Kafka Cluster"]
direction LR
broker1["Broker 1<br/><br/>Partition P0"]
broker2["Broker 2<br/><br/>Partition P1"]
broker3["Broker 3<br/><br/>Partition P2"]
end
topic --> broker1
topic --> broker2
topic --> broker3
Each partition is stored on a broker, and in real production environments those partitions are normally replicated across other brokers for fault tolerance.
This distribution is one of the reasons Kafka scales well. Different partitions can be served by different brokers, so the entire workload does not have to pass through one central machine.
How Producers and Consumers Find the Right Broker
Kafka applications normally start with a small list of bootstrap servers:
bootstrap.servers=kafka-1:9092,kafka-2:9092,kafka-3:9092
These are broker addresses used as initial entry points to discover the Kafka cluster. After discovery, the application communicates directly with the brokers responsible for the partitions it needs.
For example, a producer may first connect to Broker 1. That broker returns metadata describing the cluster, including which broker is currently the leader for each partition.
sequenceDiagram
participant P as Producer
participant B1 as Broker 1
participant B2 as Broker 2
participant B3 as Broker 3
P->>B1: Initial bootstrap connection
B1-->>P: Cluster metadata<br/>P0 leader → Broker 1<br/>P1 leader → Broker 2<br/>P2 leader → Broker 3
Note over P: Producer caches the metadata<br/>and determines the target partition
P->>B2: Send event for Partition 1
B2-->>P: Acknowledgement
Note over P,B3: Metadata is periodically refreshed<br/>to discover broker or partition leader changes
Once the producer has this metadata, it can communicate with the broker responsible for the target partition. Consumers use the same general idea when discovering where they need to read from.
Kafka clients periodically refresh this metadata, which allows them to react when brokers or partition leaders change.
Topics and Partitions
A Kafka topic is a logical stream of related events. You might have topics such as order-events, payment-events, or customer-events.
Kafka does not store an entire topic as one giant ordered list. Instead, each topic is split into partitions.
flowchart TD
topic["Topic: order-events"]
p0["Partition 0"]
p1["Partition 1"]
p2["Partition 2"]
topic --> p0
topic --> p1
topic --> p2
Each partition behaves like an ordered append-only log. New records are added to the end, and every record receives an offset that identifies its position within that partition.
flowchart LR
r0["Offset 0<br/>Record 1"]
r1["Offset 1<br/>Record 2"]
r2["Offset 2<br/>Record 3"]
r3["Offset 3<br/>Record 4"]
r4["Offset 4<br/>Record 5"]
next["New records append here"]
r0 --> r1 --> r2 --> r3 --> r4 --> next
Offsets are local to a partition. For example, Partition 0 can have an offset 10, while Partition 1 can independently have its own offset 10.
Ordering in Kafka
One of Kafka’s most useful properties is ordered processing, but the guarantee is more specific than it first appears.
Kafka guarantees ordering within a partition, not across an entire topic.
Suppose a topic has three partitions. Kafka can process those partitions independently and in parallel, so there is no global ordering guarantee across all three.
Within a single partition, however, the record sequence is preserved. This becomes especially useful when combined with message keys.
Same Key, Same Partition
A Kafka producer can publish a record with a key.
ProducerRecord<String, OrderEvent> record =
new ProducerRecord<>(
"order-events",
orderId,
orderEvent
);
producer.send(record);
Here, orderId is the key. Kafka’s partitioning logic uses that key when deciding which partition should receive the record.
With Kafka’s standard key-based partitioning, records with the same key are routed to the same partition as long as the topic’s partition count remains unchanged. So if all events for Order-123 use Order-123 as the key, the flow may look like this:
flowchart TB
e1["OrderCreated<br/>Key: Order-123"]
e2["PaymentCompleted<br/>Key: Order-123"]
e3["OrderShipped<br/>Key: Order-123"]
partitioner["Kafka Partitioner"]
note["Same key → same partition"]
subgraph p2["Partition 2"]
direction LR
r1["Offset 20<br/>OrderCreated"]
r2["Offset 21<br/>PaymentCompleted"]
r3["Offset 22<br/>OrderShipped"]
r1 --> r2 --> r3
end
e1 --> partitioner
e2 --> partitioner
e3 --> partitioner
partitioner --> note
note --> p2
style note stroke-dasharray: 5 5,stroke-width: 1.5px,fill: transparent
Because all three events are in the same partition, their sequence is preserved. This is extremely useful when later events only make sense after earlier events have been processed.
Using the order ID as the Kafka key helps preserve that ordering for all events belonging to the same order.
Ordering Comes With a Trade-Off
Choosing a Kafka key is not only a data-modelling decision. It also affects how load is distributed across partitions.
Imagine using country as the key. If most traffic comes from one country, one partition may receive a disproportionate amount of the workload.
flowchart TB
key["Kafka Key: country"]
subgraph partitions["Topic Partitions"]
direction LR
p0["Partition 0<br/>████████████████████████<br/>High traffic"]
p1["Partition 1<br/>████<br/>Low traffic"]
p2["Partition 2<br/>███<br/>Low traffic"]
p3["Partition 3<br/>████<br/>Low traffic"]
end
hot["Hot Partition<br/>Potential bottleneck"]
key --> partitions
p0 --> hot
style p0 stroke-width: 3px
style hot stroke-dasharray: 5 5,fill: transparent
This is known as a hot partition.
Because a partition can only be processed by one consumer within a consumer group at a time, a hot partition can become a bottleneck even when the rest of the cluster has spare capacity.
A useful design rule is therefore:
Choose Kafka keys based on both ordering requirements and expected data distribution.
Consumers and Consumer Groups
Consumers read records from Kafka topics. Kafka becomes much more interesting when those consumers are organised into consumer groups.
Suppose an order-events topic has three partitions. With a single consumer, that consumer can be assigned all three partitions.
flowchart TB
subgraph topic["Topic: order-events"]
direction LR
p0["Partition 0"]
p1["Partition 1"]
p2["Partition 2"]
end
consumer["Consumer A"]
p0 --> consumer
p1 --> consumer
p2 --> consumer
If more consumers join the same group, Kafka distributes the partitions between them.
flowchart TB
subgraph topic["Topic: order-events"]
direction LR
p0["Partition 0"]
p1["Partition 1"]
p2["Partition 2"]
end
subgraph group["Consumer Group"]
direction LR
c0["Consumer A"]
c1["Consumer B"]
c2["Consumer C"]
end
p0 --> c0
p1 --> c1
p2 --> c2
This is how Kafka achieves consumer-side parallelism.
Partition Count Controls Consumer Parallelism
There is one rule that is particularly important when designing Kafka consumers:
Inside the same consumer group, a partition can be assigned to only one consumer at a time.
Consider a topic with three partitions and a consumer group with four consumers.
flowchart TB
subgraph topic["Topic: order-events"]
direction LR
p0["Partition 0"]
p1["Partition 1"]
p2["Partition 2"]
end
subgraph group["Consumer Group"]
direction LR
cA["Consumer A"]
cB["Consumer B"]
cC["Consumer C"]
cD["Consumer D<br/>(Idle)"]
end
p0 --> cA
p1 --> cB
p2 --> cC
style cD stroke-dasharray: 5 5,fill: transparent
The fourth consumer has nothing to process because all partitions are already assigned.
In practical terms:
Maximum active consumers for a topic within one consumer group = number of partitions
Adding more consumers than partitions does not increase consumption throughput. The partition count sets an upper bound on how much parallelism a single consumer group can achieve.
Kafka Consumer Concurrency in Spring
This becomes particularly relevant when using Spring Kafka.
@KafkaListener(
topics = "order-events",
groupId = "payment-service",
concurrency = "3"
)
public void consume(OrderEvent event) {
process(event);
}
Conceptually, Spring creates multiple Kafka consumer instances for the listener container.
If the topic has six partitions and the listener concurrency is three, Kafka may distribute the partitions roughly like this:
flowchart TB
listener["Spring @KafkaListener<br/>concurrency = 3"]
subgraph consumers["Listener Container"]
direction LR
c1["Consumer 1"]
c2["Consumer 2"]
c3["Consumer 3"]
end
subgraph topic["Topic: order-events — 6 Partitions"]
direction LR
p0["P0"]
p1["P1"]
p2["P2"]
p3["P3"]
p4["P4"]
p5["P5"]
end
listener --> consumers
c1 --> p0
c1 --> p3
c2 --> p1
c2 --> p4
c3 --> p2
c3 --> p5
The exact assignment is controlled by Kafka and may differ, but the important point is that all six partitions can be shared across those three consumer instances.
Now imagine the topic has only three partitions while the listener is configured with a concurrency of ten. Only three consumers can actively receive partitions, while the rest remain idle.
flowchart TB
listener["Spring @KafkaListener<br/>concurrency = 10"]
subgraph topic["Topic: order-events | 3 Partitions"]
direction LR
p0["P0"]
p1["P1"]
p2["P2"]
end
subgraph active["Active Consumers"]
direction LR
c1["Consumer 1"]
c2["Consumer 2"]
c3["Consumer 3"]
end
idle["Consumers 4–10<br/>Idle"]
listener --> active
listener --> idle
p0 --> c1
p1 --> c2
p2 --> c3
style idle stroke-dasharray: 5 5,fill: transparent
So increasing the listener concurrency does not automatically make processing faster. The topic must have enough partitions to support the desired level of parallelism.
Multiple Consumer Groups
Kafka also allows several independent consumer groups to read the same topic.
Imagine an OrderPlaced event that is useful to several systems.
The payment service, analytics service, and notification service can each use their own consumer group.
Each group consumes independently and maintains its own position in the topic.
flowchart TB
topic["Topic: order-events<br/>OrderPlaced"]
subgraph groups["Consumer Groups"]
direction LR
pg["payment-service-group<br/>Offset 1500"]
ag["analytics-service-group<br/>Offset 1432"]
ng["notification-service-group<br/>Offset 1498"]
end
payment["Payment Service"]
analytics["Analytics Service"]
notification["Notification Service"]
topic --> pg
topic --> ag
topic --> ng
pg --> payment
ag --> analytics
ng --> notification
If the analytics service falls behind, it does not prevent the payment or notification services from continuing. Each group moves through the event stream at its own pace.
Kafka Does Not Remove an Event Because You Read It
This is one of the most important conceptual differences to understand.
Imagine a partition containing these offsets:
flowchart LR
subgraph partition["Partition 1"]
direction LR
o100["Offset 100<br/>Record 1"]
o101["Offset 101<br/>Record 2"]
o102["Offset 102<br/>Record 3"]
o103["Offset 103<br/>Record 4"]
o104["Offset 104<br/>Record 5<br/>▲<br/>(Next to read)"]
o105["Offset 105<br/>Record 6"]
o100 --> o101 --> o102 --> o103 --> o104 --> o105
end
Kafka does not remove the records at offsets 100 through 103 simply because that consumer has processed them. Those records remain according to the topic’s retention policy.
The consumer keeps track of the position from which it should continue consuming.
That design enables one of Kafka’s most powerful capabilities: replay.
Replaying Events
Imagine an analytics service contained a bug and calculated customer metrics incorrectly for six hours. The events have already been processed, but Kafka may still retain them.
If so, the consumer can move its position backwards and process those records again.
Reset the consumer position:
flowchart LR
subgraph partition["Partition 1"]
direction LR
o100["Offset 100<br/>Record 1"]
o101["Offset 101<br/>Record 2<br/>▲<br/>(Replay from here)"]
o102["Offset 102<br/>Record 3"]
o103["Offset 103<br/>Record 4"]
o104["Offset 104<br/>Record 5"]
o105["Offset 105<br/>Record 6<br/>△<br/>(Previous position)"]
o100 --> o101 --> o102 --> o103 --> o104 --> o105
end
The consumer can now replay those records.
The previous position at offset 105 represents the next record to read. Resetting the position to offset 101 causes the consumer to replay the records it had already processed from 101 through 104, then continue with 105.
Replay is useful for scenarios such as rebuilding projections, recalculating analytics, recovering from consumer bugs, rebuilding search indexes, and reprocessing failed events.
However, replay needs to be designed carefully. Reprocessing an analytics event is very different from reprocessing an event that sends an email, charges a payment method, or calls an external system.
Kafka gives you the ability to replay events, but your application still needs to ensure replay is safe. This is one reason idempotency is so important in event-driven systems.
Pause and Resume
Sometimes a Kafka consumer is healthy while one of its downstream dependencies is not.
Continuing to consume records aggressively may make the situation worse. Kafka consumers can temporarily pause assigned partitions.
At the low-level Kafka consumer API, the idea looks like this:
consumer.pause(consumer.assignment());
Pausing does not delete records or move the consumer forward. It simply prevents the consumer from fetching more records from those partitions temporarily.
Once the downstream dependency becomes available again, consumption can be resumed:
consumer.resume(consumer.assignment());
flowchart LR
kafka["Kafka<br/>Partitions"]
consumer["Consumer"]
apiDown["External API<br/>Unavailable"]
paused["⏸ Consumer Paused"]
apiUp["External API<br/>Available"]
resumed["▶ Consumer Resumed"]
kafka --> consumer
consumer -.-> apiDown
apiDown --> paused
paused --> apiUp
apiUp --> resumed
resumed --> consumer
style apiDown stroke-dasharray: 5 5,fill: transparent
style paused stroke-dasharray: 5 5,fill: transparent
This can be useful during downstream outages, temporary rate limiting, controlled maintenance, or backpressure situations. Frameworks such as Spring Kafka provide higher-level ways to manage pause and resume, so applications may not always interact with the raw consumer API directly.
What Happens When Consumers Come and Go?
Suppose a consumer group initially has two consumers.
If another consumer joins the group, Kafka redistributes the partitions.
This process is known as a consumer group rebalance.
The same thing happens when a consumer disappears. If Consumer B crashes, Kafka eventually detects that it is no longer part of the group and reassigns its partition to another active consumer.
flowchart LR
subgraph before["Initial Consumer Group"]
a1["Consumer A<br/>P0, P1"]
b1["Consumer B<br/>P2, P3"]
a1 ~~~ b1
end
join["Consumer C Joins<br/>Rebalance"]
subgraph afterJoin["After Rebalance"]
a2["Consumer A<br/>P0"]
b2["Consumer B<br/>P1"]
c2["Consumer C<br/>P2, P3"]
a2 ~~~ b2
b2 ~~~ c2
end
crash["Consumer B Crashes<br/>Rebalance"]
subgraph afterCrash["After Rebalance"]
a3["Consumer A<br/>P0, P1"]
c3["Consumer C<br/>P2, P3"]
a3 ~~~ c3
end
before --> join
join --> afterJoin
afterJoin --> crash
crash --> afterCrash
style join stroke-dasharray: 5 5,fill: transparent
style crash stroke-dasharray: 5 5,fill: transparent
This is what allows Kafka consumer applications to scale horizontally and recover from individual consumer failures without manually redistributing work.
Rebalancing itself has many interesting behaviours and tuning options, but that is a topic for another article.
What Happens When a Broker Fails?
Kafka also distributes replicas of partitions across brokers for fault tolerance.
Producers and consumers normally communicate with the broker responsible for the relevant partition leader.
If Broker 1 fails, Kafka can elect an eligible replica as the new leader.
flowchart LR
subgraph before["Before Broker Failure"]
direction TB
b1["Broker 1<br/><br/>P0 Leader"]
b2["Broker 2<br/><br/>P0 Replica"]
b1 ~~~ b2
end
failure["Broker 1 Fails<br/>Leader Election"]
subgraph after["After Leader Election"]
direction TB
b1f["Broker 1<br/><br/>Unavailable"]
b2l["Broker 2<br/><br/>P0 Leader"]
b1f ~~~ b2l
end
metadata["Client Refreshes<br/>Cluster Metadata"]
continue["Continue with<br/>New Leader"]
before --> failure
failure --> after
after --> metadata
metadata --> continue
style failure stroke-dasharray: 5 5,fill: transparent
style b1f stroke-dasharray: 5 5,fill: transparent
Kafka clients refresh their cluster metadata and discover that the partition leader has changed. This allows processing to continue without every application having to manually find another Kafka server.
Replication, leader election, acknowledgements, and Kafka’s internal fault-tolerance mechanisms go much deeper than this, but the important mental model is simple: partitions are distributed and replicated across brokers so that the Kafka cluster does not depend on one machine.
Partition Count Is an Architectural Decision
Suppose a topic has three partitions and your application has three consumers. All three consumers can be actively processing at the same time.
Now traffic increases significantly and you deploy ten consumers. If the topic still has only three partitions, only three of those consumers can actively consume from that topic.
Partitions are therefore not simply an internal Kafka detail. They directly influence maximum consumer parallelism, throughput, ordering, load distribution, and scalability.
It is possible to increase a topic’s partition count later, but that also deserves some thought. Kafka commonly chooses a partition by hashing the record key and then mapping that result across the topic’s available partitions. If the number of partitions changes, the same key may map to a different partition for future records.
For example, imagine all events for Order-123 have historically been written to Partition 2. After increasing the topic from three partitions to six, new records for Order-123 may start going to Partition 4 instead.
flowchart LR
subgraph before["Before: 3 Partitions"]
direction TB
created["OrderCreated<br/>Key: Order-123"]
paid["OrderPaid<br/>Key: Order-123"]
p2a["Partition 2"]
created --> p2a
paid --> p2a
end
change["Partition Count<br/>3 → 6"]
subgraph after["After: 6 Partitions"]
direction TB
shipped["OrderShipped<br/>Key: Order-123"]
p4["Partition 4"]
shipped --> p4
end
before --> change
change --> after
style change stroke-dasharray: 5 5,fill: transparent
The records inside each partition are still ordered, but Kafka does not provide ordering across Partition 2 and Partition 4. If consumers are processing both partitions in parallel, Order-123 Shipped could theoretically be processed before an older event for the same order that is still waiting in Partition 2.
This is why partition count should ideally be considered early when ordering by key is important. Increasing partitions is completely valid, but applications that depend on long-lived key-based ordering need to understand that the key-to-partition mapping may change.
When Kafka Starts to Make Sense
Kafka is powerful, but asynchronous communication alone is not necessarily a reason to introduce it.
Kafka becomes particularly useful when several requirements appear together: high event throughput, multiple independent consumers, replayability, ordering within a logical entity, horizontal consumer scaling, or event-driven processing.
For example, a single event may need to be consumed independently by payment processing, inventory, analytics, notifications, and fraud detection.
flowchart LR
event["OrderPlaced"]
subgraph consumers["Independent Consumers"]
direction TB
payment["Payment Processing"]
inventory["Inventory"]
analytics["Analytics"]
notifications["Confirmation Email"]
fraud["Fraud Detection"]
end
event --> payment
event --> inventory
event --> analytics
event --> notifications
event --> fraud
Kafka also fits naturally when systems react to facts that have already happened, such as OrderPlaced, PaymentCompleted, CustomerRegistered, or DocumentUploaded.
This is where Kafka’s model becomes especially valuable: producers publish facts, and interested systems decide independently how to process them.
Kafka Is Not Automatically the Right Answer
Kafka comes with operational and architectural complexity, so it should solve a real problem rather than simply be introduced because it is popular.
For a small application that only needs to execute background jobs, a traditional queue may be easier to operate and easier to reason about.
Kafka is also not a replacement for normal HTTP APIs. If a service needs an immediate answer, request-response communication is often still the natural choice.
GET /customer/123
Kafka becomes valuable when its event-stream model actually fits the architecture. Choosing it simply because “we use microservices” or “Kafka scales” can add complexity without providing much benefit.
The Kafka Mental Model to Remember
There are many Kafka concepts we have not covered here: replication factors, in-sync replicas, producer acknowledgements, delivery semantics, idempotent producers, transactions, dead-letter topics, schema evolution, Kafka Streams, KRaft, and different rebalancing strategies.
Those details matter, but they are much easier to understand once the basic model is clear.
Do not imagine Kafka primarily as messages travelling through a pipe. Imagine a distributed log.
flowchart LR
producer["Producer"]
subgraph kafka["Kafka Cluster"]
direction TB
topic["Topic"]
subgraph partitions["Partitions"]
direction LR
p0["P0"]
p1["P1"]
p2["P2"]
end
topic --> partitions
end
subgraph group["Consumer Group"]
direction TB
c1["Consumer A"]
c2["Consumer B"]
c3["Consumer C"]
end
producer -->|" Append Events "| topic
p0 --> c1
p1 --> c2
p2 --> c3
Producers append events, Kafka stores them, partitions provide ordering and scalability, and brokers distribute those partitions across a cluster.
Consumers independently keep track of where they are, while consumer groups allow processing to scale horizontally. Because consuming an event does not immediately destroy it, consumers can pause, resume, fall behind, recover, and replay history.
Kafka can feel complicated at first because there are many moving parts. But once topics, partitions, brokers, offsets, and consumer groups fit together, the architecture becomes much easier to reason about.
At its core, Kafka gives you a scalable way to publish events, distribute them across a cluster, preserve ordering where it matters, and let multiple consumers process those events independently.
The important part is not knowing every Kafka feature. It is understanding the model well enough to recognise when Kafka fits the problem you are trying to solve.
References
- Apache Kafka — Introduction and core concepts
- Apache Kafka — Producer configuration (bootstrap servers and partitioning)
- Apache Kafka — Consumer API (groups, offsets, replay, pause/resume, and rebalancing)
- Apache Kafka — Design (replication and fault tolerance)
- Apache Kafka — Basic operations (modifying topics and adding partitions)
- Apache Kafka — Use cases
- Spring for Apache Kafka — Message listener containers and concurrency
- Spring for Apache Kafka — Pausing and resuming listener containers