The consumer pulls
A Kafka consumer pulls. It sends a fetch request to the leader of each partition it reads, naming the offset it wants next, and the leader answers with the records from that offset forward. The call that does this is poll. One call can return hundreds of records from several partitions, or none at all when nothing new has arrived, and the consumer decides when to call again. The leader tracks nothing about what the consumer does with those records; it answers fetches. A broker acting as the group coordinator keeps track of which consumers read together and where each one has got to, and sections 1 and 2 cover it.
The log lesson showed that a record sits at an offset in a partition and that reading leaves it there. A consumer turns that into a loop: subscribe to a topic, call poll, handle every record in the batch, call poll again. The loop below is the whole job of a billing service that reads the orders topic and charges a card for each one.
Suppose the loop's 2nd poll returns 3 records. The consumer did not ask for offsets 4 and 5 of partition 0 by name; it asked for everything from its current position on each partition it holds, and the 2 leaders sent what they had. Within 1 partition the batch keeps offset order, 4 before 5. Across partitions there is no order, and partition 1's record may arrive before or after partition 0's.
| topic | partition | offset | key | value |
|---|---|---|---|---|
| orders | 0 | 4 | cust-17 | {amount: 42.00} |
| orders | 0 | 5 | cust-3 | {amount: 9.99} |
| orders | 1 | 12 | cust-8 | {amount: 120.00} |
After that batch the consumer remembers, in its own memory, that partition 0 is at 6 and partition 1 is at 13, and the next poll fetches from those positions. The position is in the consumer's memory alone; nothing has been saved to the broker. Saving it is the commit, which section 2 covers.
What one poll may return
max.poll.records caps how many records a single poll hands to your code, 500 by default; the consumer fetches more than that in the background and parcels them out call by call. fetch.min.bytes and fetch.max.wait.ms tell the leader how long it may hold a fetch open while data piles up before it answers. With the defaults of 1 byte and 500 ms, the leader answers as soon as it has anything. max.partition.fetch.bytes limits how much of 1 partition comes back in 1 fetch, so a batch of 1 KB records from 1 partition tops out near 1,000 records in the buffer.
| setting | default | what it bounds |
|---|---|---|
| max.poll.records | 500 | records one poll hands to your code |
| fetch.min.bytes | 1 | bytes the leader gathers before answering |
| fetch.max.wait.ms | 500 | how long the leader may wait for fetch.min.bytes |
| max.partition.fetch.bytes | 1048576 | bytes fetched from one partition at a time |
| max.poll.interval.ms | 300000 | time allowed between two poll calls |
poll is the longest it waits when no records are available. When records are already buffered, poll returns at once whatever the timeout, so a 1-second timeout costs nothing on a busy partition.The deadline between polls
Between one poll and the next, the consumer does its work on the batch: it charges the cards, writes the rows, calls the downstream service. The broker does not watch this work; it sees only the next fetch. The consumer must call poll again within max.poll.interval.ms, 5 minutes by default. A batch of 500 records that take 1 second each finishes in about 8 minutes. That is past the deadline, so the group treats the consumer as gone. What the group does about it comes in a later lesson; for now, size the batch so the loop returns in time: 500 records at 1 second each needs max.poll.records under 300, or a handler 2 times faster.
- ▸Subscribe once to the topics the consumer reads.
- ▸Poll: fetch from the current position on each partition held, waiting up to the timeout.
- ▸Process every record the batch returned.
- ▸Commit, then poll again before
max.poll.interval.msruns out.
One partition, one reader at a time
A consumer joins a group by setting group.id. Every member of the group subscribes to the same topics, and the group as a whole reads every record once: each partition is handed to exactly 1 member, and that member reads it alone until the membership changes. A topic with 6 partitions read by a group of 3 gives each member 2 partitions. The same topic read by a group of 6 gives each member 1. A 7th member joins and finds nothing left to hand out. It keeps polling and getting empty batches until a partition frees up.
| members in the group | partitions each holds | idle members |
|---|---|---|
| 1 | 6 | 0 |
| 2 | 3 and 3 | 0 |
| 3 | 2 each | 0 |
| 4 | 2, 2, 1, 1 | 0 |
| 6 | 1 each | 0 |
| 7 | 1 each, 1 holds nothing | 1 |
Who deals the partitions
One broker acts as the group's coordinator. When a member starts, it tells the coordinator its group.id and the topics it subscribes to. The coordinator keeps the member list, and it calls a fresh deal whenever a member joins or leaves, cleanly or by crashing. Under the default protocol the deal itself is computed by 1 member, the group's leader, and handed to every member through the coordinator, so that every partition has 1 reader and no partition has 2. The default dealing spreads them as evenly as the counts allow: 6 partitions over 4 members come out 2, 2, 1, 1. How a member hands over a partition it was in the middle of reading is a later lesson; here the invariant is what matters: at any moment a partition has at most 1 reader in the group.
- ▸A partition has at most 1 reader per group.
- ▸A member may hold any number of partitions, including none.
- ▸Members beyond the partition count hold nothing and wait.
- ▸Each group keeps its own position on every partition.
2 groups, 2 full copies of the stream
Groups do not share work with each other. A billing group and a fraud group both subscribed to orders each receive every record, and each keeps its own position. The split happens inside a group, never across groups. This is what lets 1 topic feed 10 independent systems with none of them seeing a subset, and it's why reading does not delete: 9 other groups may still need the record.
- Partitions split between them
- Each record read by 1 of the 2
- Adding the 2nd halves the 1st one's work
- Both share 1 committed position per partition
- Each reads every partition
- Each record read by both
- Adding the 2nd changes nothing for the 1st
- Separate committed positions
Each group reads all 6 partitions of orders. Inside billing, 3 members hold 2 partitions each; inside fraud, 2 members hold 3 each; the warehouse loader holds all 6 alone.
In the animation, every record with key cust-2 lands on 1 partition, and that partition belongs to 1 member, so 1 member sees all of that customer's orders in order. With 2 members each holds 2 partitions; with 5, 4 members hold 1 each and billing-e holds nothing. Use the crash control to stop billing-b: once the coordinator notices it is gone, its partitions are dealt to the others, and when it restarts it rejoins and the group deals again.
The committed offset is the next offset to read
A consumer's position lives in its memory until it commits. A commit sends the group's coordinator 1 number per partition: the offset of the next record to read, which is the last processed offset plus 1. After processing offsets 7 through 9 of partition 0, the consumer commits 10. The coordinator appends that number to an internal topic named __consumer_offsets, keyed by the group and the topic-partition, so the committed position belongs to the group rather than to the member that sent it. Any member that holds partition 0 later asks the coordinator for the group's committed offset and starts at 10.
| step | position in memory | committed offset | what happens |
|---|---|---|---|
| poll returns 7, 8, 9 | 10 | 7 | handler processes 3 records |
| commit | 10 | 10 | coordinator stores 10 for billing, orders, 0 |
| poll returns 10, 11, 12 | 13 | 10 | handler processes 3 records |
| crash before commit | gone | 10 | partition 0 is dealt to another member, or waits |
| restart, ask for committed offset | 10 | 10 | reads 10, 11, 12 a 2nd time |
Commit by hand or on a timer
With enable.auto.commit=true, the default, the consumer commits on its own: during a poll, if auto.commit.interval.ms has elapsed since the last commit, 5 seconds by default, it commits the position its memory holds, and your loop never calls commit. The timer runs on the consumer's clock and takes no notice of whether your handler finished with the batch; what that means for a crash in the middle of one is the next lesson's first subject. With enable.auto.commit=false, nothing is committed until the loop calls commit, and the loop picks the moment: once a batch, once every 100 records, or right after a downstream write succeeds.
commit comes in 2 forms. The synchronous form blocks until the coordinator confirms, so the loop pauses for a round trip but knows the position was saved before the next poll. The asynchronous form sends the commit and returns at once, so the loop keeps polling and the confirmation, or the failure, arrives later. The Java client names them commitSync and commitAsync. A loop that commits once a batch usually affords the synchronous round trip; a loop that commits after every record usually cannot.
No committed offset, so the setting decides
The committed offset answers where a consumer resumes. A group that has never committed has no answer: the coordinator looks in __consumer_offsets for the group's commit on that partition and finds nothing. The consumer then falls back to auto.offset.reset, a setting with 3 main values. latest, the default, starts at the end of the partition, so the consumer sees only records written after it first asked. earliest starts at the oldest offset the partition holds, 0 for a fresh topic, and reads everything. none raises an error and leaves the decision to you. Kafka 4.x also accepts a duration form, by_duration: followed by a span such as PT1H, which starts at the first record written within that span before now.
| value | where the first poll starts | what the consumer sees |
|---|---|---|
| latest (default) | the end of the partition | only records produced after it starts |
| earliest | the oldest offset on the partition | every record the partition holds, then new ones |
| none | nowhere; poll raises an error | nothing until you assign a position by hand |
Why the default surprises people
Take a topic orders with 5,000 records on 1 partition. A developer writes a new consumer with group.id=report-v1 and starts it, but no records arrive. The log shows the consumer joined its group and holds the partition, and poll keeps returning empty batches. The consumer is working as configured: with latest it began at offset 5,000, and no record has been written since. The 5,000 records are still there, as the log lesson promised, but this consumer stepped over all of them. The developer who expected a backfill wanted earliest; the developer running a live alerting service that must never replay a week of old events wanted latest. The default assumes the second case.
The setting applies only when the group has no valid committed offset on a partition. For a group that commits regularly, that is its first start, plus 2 rare cases: the committed offset points at records the broker has since deleted, or the group's commits expired while it had no members, which the tip below describes. On every other restart the committed offset wins. Changing the setting from latest to earliest on a group with a valid commit does nothing; the group resumes from its commit. To read from the start with such a group, either use a new group.id, which has no commits, or reset the group's offsets, a tool covered in a later lesson.
A group.id nobody has used is a new group, even when the code is the same. Rename report-v1 to report-v2 and redeploy, and the service starts under the reset setting again: with latest it skips everything written before the deploy, with earliest it reprocesses the topic from the start. Teams use that on purpose, giving a rebuilt service a fresh id and earliest to recompute a table from history, and by accident, when a config template changes the id and a live service silently skips an hour of events.
The producer writes 5 records before the consumer starts. Under latest, the consumer's first fetch asks for offset 5, and the 3 records the producer writes afterwards are the only ones it reads. Under earliest, the first fetch asks for 0, and it reads 8. Both runs commit after reading, so if either consumer restarts it continues from its commit and the setting no longer matters.
offsets.retention.minutes default of 10080. A consumer switched off for longer comes back with nothing saved, and under latest it skips every record written while it was away.- ▸earliest: backfills, rebuilding a table, any reader that must see the history already on the topic.
- ▸latest: live alerting, dashboards of the present, any reader that must never replay old events.
- ▸none: a group whose starting point a person sets by hand, and which must fail loudly when it is missing.
Lag is a subtraction per partition
Describing a group
The command that prints the numbers ships with Kafka: bin/kafka-consumer-groups.sh --bootstrap-server broker-1:9092 --describe --group billing. It prints 1 row per partition the group reads, with the member holding each one.
| TOPIC | PARTITION | CURRENT-OFFSET | LOG-END-OFFSET | LAG | CONSUMER-ID |
|---|---|---|---|---|---|
| orders | 0 | 8400 | 8420 | 20 | billing-a-7f3c |
| orders | 1 | 8391 | 8391 | 0 | billing-a-7f3c |
| orders | 2 | 2110 | 9876 | 7766 | billing-b-21e9 |
| orders | 3 | 8405 | 8412 | 7 | billing-b-21e9 |
CURRENT-OFFSET is the committed offset; the tool names it from the group's point of view. Partitions 0 and 1 sit within a few records of their ends, as does partition 3. Partition 2 is 7,766 behind while its reader, billing-b, keeps partition 3 nearly current, so that member is alive and committing. One reading can't say why partition 2 trails. Compare how fast each partition's log end grows between readings: if partition 2's grows far faster than the others, a hot key is sending it most of the traffic, the skew the producers lesson named; if it grows at their pace, suspect the handler, which is slow on partition 2's records or stuck on one of them. Either way, adding members won't help, because partition 2 can only ever have 1 reader.
| reading | CURRENT-OFFSET | LOG-END-OFFSET | LAG |
|---|---|---|---|
| 10:00 | 2110 | 9876 | 7766 |
| 10:10 | 4510 | 15876 | 11366 |
| 10:20 | 6910 | 21876 | 14966 |
Lag's direction matters more than its size
- The group keeps pace with the producers
- The number is about a batch size or a commit interval
- A spike after a deploy that drains in minutes
- Nothing to do but keep watching
- The group reads slower than the producers write
- Every partition growing: too few members, or a slow handler
- 1 partition growing: a hot key, or 1 member stuck
- Every CONSUMER-ID blank: no member holds anything
A lag of 50,000 that was 500,000 an hour ago is a group catching up after an outage. A lag of 500 that was 50 yesterday and 5 the day before is a service losing ground slowly. When every CONSUMER-ID is blank, the group has no live members: its committed offsets stand still and lag grows at exactly the producers' rate until a member returns and resumes from them. Record the total and the per-partition lag on a schedule and graph it, because the describe command shows only the moment you run it.
- Alert on lag that grows for 10 minutes, rather than on lag above a fixed number
- Read lag per partition before adding members
- Check CONSUMER-ID when lag grows on every partition at once
- Commit often enough that lag reflects processing, within a batch
- Add members past the partition count expecting lag to drop
- Treat a lag of 0 as proof the sink received the records
- Compare lags across groups as if 1 number meant 1 thing
- Judge a group from 1 reading of the command
> A marketplace's orders topic has 12 partitions. The billing group runs 6 members that commit after every batch, the fraud group runs 12, and a new reporting team launches report-v1 on Friday with default settings. By Monday the reporting group is 40 million records behind, and 1 billing member crashed and restarted twice overnight.
latest started it at the end of each partition; the records it stepped over were still on the log, readable under earliest with a new group id.poll fetches from the consumer's in-memory position on each partition it holds and returns a batch capped by max.poll.records; the loop must call it again within max.poll.interval.ms.__consumer_offsets per group and partition; after a restart the consumer reads from it, so uncommitted records are read again.auto.offset.reset says: latest by default, which steps over everything already in the topic.Apache Kafka is an open-source distributed event streaming platform used by thousands of companies for high-performance data pipelines, streaming analytics, data integration, and mission-critical applications.
kafka.apache.org
- The Poll Loop
- The Consumermax.poll.records
- Groups Split Partitions
- group.idConsumer Rebalance Protocol
- Committing an Offset
- Consumer Positionenable.auto.commit
- Where a New Group Starts
- auto.offset.reset
- Reading Consumer Lag
- Checking consumer positionManaging consumer groups
Apache Kafka, Kafka, and the Kafka logo are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries.
Follow a consumer from its first poll to its committed offset, and read how far behind its group has fallen.
- Category
- Kafka
- Difficulty
- beginner
- Duration
- 20 minutes
- Challenges
- 10 hands-on challenges
Topics covered: The Poll Loop, Groups Split Partitions, Committing an Offset, Where a New Group Starts, Reading Consumer Lag
Lesson Sections
- The Poll Loop (concepts: Consumer groups)
The consumer pulls What one poll may return The deadline between polls Watch the consumer's position in the animation. After each batch it moves forward by the batch size, and the next fetch asks for exactly that offset. The setting changes how many records the consumer holds at once between polls, and nothing about which records it reads or in what order.
- Groups Split Partitions (concepts: Consumer groups)
One partition, one reader at a time The partition count is the ceiling on parallel readers. The 7th member adds no throughput; it adds a standby that takes over a partition when another member leaves. A group that needs more parallel readers needs a topic with more partitions, a number chosen when the topic is created. The split is also what keeps a key's records in order for a reader: all of customer 17's orders sit on 1 partition, that partition has 1 reader, so that reader sees them in the or
- Committing an Offset (concepts: Offset commits)
The committed offset is the next offset to read The internal topic obeys everything the earlier lessons established about topics: it has partitions and replicas, and each commit is appended to it. Each new commit for a group and partition is a new record with a newer offset number inside it, and the coordinator keeps the latest in memory to answer quickly. Because the replicas carry the topic, a committed offset survives the consumer that wrote it, the member that replaces it, and the broker tha
- Where a New Group Starts (concepts: Consumer groups)
No committed offset, so the setting decides Why the default surprises people
- Reading Consumer Lag (concepts: Consumer lag)
Lag is a subtraction per partition Every partition carries 2 numbers a group cares about: the log end offset, 1 past the newest record the leader holds, and the group's committed offset. Lag is the difference. A partition whose log end is 8,420 and whose committed offset is 8,400 has a lag of 20: the group has 20 records it has not yet committed. Lag belongs to a partition and a group, so a topic read by 3 groups has 3 lags per partition, and a group reading 6 partitions has 6 lags, usually summ