Skip to main content
PProfileMatch

Kafka Producers, Consumers and Consumer Groups Explained

System Design & ArchitectureLesson 2 of 46 min read

What you'll learn

  • Configure a producer's key settings: acks, batching and compression
  • Explain how a consumer group divides partitions and why extra consumers sit idle
  • Describe what committing an offset means and when it should happen
  • Read consumer lag and understand what causes a rebalance

On this page

  1. The producer side
  2. Consumer groups: dividing the work
  3. Commits: how a group remembers its place
  4. What triggers a rebalance
  5. Common mistakes
  6. How this shows up in real projects and interviews

A courier-dispatch service reads delivery requests from Kafka. At the lunch rush it falls behind, so someone starts a second copy of the service. If both copies used different group ids, every request would be dispatched twice. If they share a group id but the topic has one partition, the second copy would do nothing. Consumer groups are the mechanism behind both outcomes, and understanding them is what separates "I can send and receive a message" from "I can scale a consumer".

The producer side

A producer does not send one network request per record. It places records in per-partition batches in memory and ships a batch when it is full or when a short delay passes. Three settings govern the trade-off between latency, throughput and safety.

SettingWhat it controlsTypical reasoning
acksHow many replicas must confirm a write: 0 none, 1 the leader, all every in-sync replicaall for data you cannot lose; 1 or 0 only for disposable telemetry
linger.msHow long to wait for more records before sending a batchA few milliseconds of waiting often improves throughput a lot
batch.sizeMaximum bytes per partition batchBigger batches mean fewer requests but more memory and a little latency
compression.typeCompress whole batches: none, gzip, snappy, lz4, zstdSaves network and disk on text-heavy payloads at some CPU cost

Here is a producer written with the confluent-kafka Python library. It was not executed for this lesson because there is no broker here, so check the library version's documentation before copying it, but the call shapes are the standard ones: produce is asynchronous, a delivery callback reports success or failure later, and flush blocks until everything is acknowledged.

dispatch_producer.py (needs a broker; not run here)
import json
from confluent_kafka import Producer

producer = Producer({
    "bootstrap.servers": "localhost:9092",
    "acks": "all",
    "linger.ms": 5,
    "compression.type": "lz4",
})

def on_delivery(err, msg):
    if err is not None:
        print("delivery failed:", err)
    else:
        print(f"stored in {msg.topic()} partition {msg.partition()} offset {msg.offset()}")

request = {"order_id": "A-1042", "pickup": "Baner", "drop": "Kothrud"}
producer.produce(
    "delivery-requests",
    key=request["order_id"],
    value=json.dumps(request),
    on_delivery=on_delivery,
)
producer.poll(0)   # serve delivery callbacks
producer.flush()   # wait for outstanding acks before the program exits

The call to produce returning does not mean the record is stored; it means the record was queued locally. Skipping flush() at shutdown is how records silently vanish from short scripts.

Consumer groups: dividing the work

Every consumer belongs to a group identified by group.id. Kafka's rule is simple: within one group, each partition is assigned to exactly one consumer at a time. Different groups are independent and each receives every record, which is how the billing and analytics services share one topic. Scaling a service means starting more consumers with the same group id, up to the number of partitions, a horizontal-scaling pattern covered more generally in Scalability and Load Balancing. The sketch below models the assignment, using made-up offsets to compute lag, which is the distance between the end of the log and what the group has committed.

group_sim.py
def assign(partitions, members):
    """Spread partitions over members round-robin (a simplified assignor)."""
    plan = {m: [] for m in sorted(members)}
    names = sorted(members)
    for i, p in enumerate(partitions):
        if names:
            plan[names[i % len(names)]].append(p)
    return plan

partitions = list(range(6))
log_end = {0: 120, 1: 118, 2: 140, 3: 90, 4: 101, 5: 99}   # next offset to be written
committed = {0: 100, 1: 100, 2: 100, 3: 90, 4: 90, 5: 99}  # group's committed offsets

def show(label, members):
    print(label)
    plan = assign(partitions, members)
    for m, ps in plan.items():
        lag = sum(log_end[p] - committed[p] for p in ps)
        print(f"  {m}: partitions {ps} lag={lag}")
    if not plan:
        print("  (no members)")

show("3 consumers in group 'dispatch':", {"c-a", "c-b", "c-c"})
show("c-b crashes -> rebalance:", {"c-a", "c-c"})
show("10 consumers, 6 partitions:", {f"c-{i}" for i in range(10)})

Output

3 consumers in group 'dispatch':
  c-a: partitions [0, 3] lag=20
  c-b: partitions [1, 4] lag=29
  c-c: partitions [2, 5] lag=40
c-b crashes -> rebalance:
  c-a: partitions [0, 2, 4] lag=71
  c-c: partitions [1, 3, 5] lag=18
10 consumers, 6 partitions:
  c-0: partitions [0] lag=20
  c-1: partitions [1] lag=18
  c-2: partitions [2] lag=40
  c-3: partitions [3] lag=0
  c-4: partitions [4] lag=11
  c-5: partitions [5] lag=0
  c-6: partitions [] lag=0
  c-7: partitions [] lag=0
  c-8: partitions [] lag=0
  c-9: partitions [] lag=0

Real assignors (range, sticky, cooperative sticky) use different rules, so which partitions go to which consumer will differ, but the invariants are the same. The ceiling on parallelism is the partition count: ten consumers on six partitions leave four idle. When a member leaves, its partitions move to the survivors, which is the rebalance, and the survivors' lag jumps because they now carry more.

Commits: how a group remembers its place

A consumer's progress is stored in Kafka itself, in an internal topic called __consumer_offsets, keyed by group, topic and partition. By convention the committed value is the offset of the next record to read, so after fully handling offset 41 you commit 42. By default the client commits automatically on a timer; that is convenient and also the source of lost or repeated work, because the timer knows nothing about whether your processing finished. For anything with side effects, turn auto-commit off and commit after processing.

dispatch_consumer.py (needs a broker; not run here)
import json
from confluent_kafka import Consumer

consumer = Consumer({
    "bootstrap.servers": "localhost:9092",
    "group.id": "dispatch",
    "enable.auto.commit": False,
    "auto.offset.reset": "earliest",   # only used when the group has no committed offset
})
consumer.subscribe(["delivery-requests"])

try:
    while True:
        msg = consumer.poll(1.0)
        if msg is None:
            continue
        if msg.error():
            print("consumer error:", msg.error())
            continue
        request = json.loads(msg.value())
        print("assigning courier for", request["order_id"])   # your real work here
        consumer.commit(message=msg, asynchronous=False)       # commit AFTER the work
finally:
    consumer.close()   # leave the group cleanly so partitions move immediately

To watch a real group, the CLI shows committed offset, log end and lag per partition. The output below is illustrative of the column layout, not of real numbers.

Inspect a group
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group dispatch

Output

GROUP     TOPIC              PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID
dispatch  delivery-requests  0          100             120             20   c-a-1f3...
dispatch  delivery-requests  1          100             118             18   c-b-77c...

What triggers a rebalance

A rebalance happens when a consumer joins, leaves, or is considered dead. The last case is the one that surprises people. A consumer stays in the group by calling poll regularly and sending background heartbeats. If your processing of one batch takes longer than max.poll.interval.ms, the group coordinator assumes the consumer is stuck, removes it and reassigns its partitions, and the work it was doing is handed to someone else while it is still running. Shrink max.poll.records or speed up processing before raising timeouts, and prefer the cooperative rebalancing assignor so unaffected consumers keep working during a rebalance.

Common mistakes

  • Different `group.id` per instance. Each instance becomes its own group and every record is processed once per instance. Symptom: duplicate emails or double dispatches right after scaling out.
  • Scaling consumers beyond the partition count. The extra consumers show no assignments and no throughput gain. Symptom: adding pods does not reduce lag.
  • Committing before processing. If the process crashes after the commit, the record is never handled. Symptom: a few records missing after every deployment.
  • Slow handlers causing endless rebalances. Logs fill with "member was removed from the group" style messages and lag oscillates. The cause is exceeding max.poll.interval.ms, not a Kafka fault.
  • Forgetting `flush()` or `close()`. Producers lose queued records on exit; consumers delay failover until the session timeout expires.

How this shows up in real projects and interviews

Interviewers love the scaling question: "your consumers can't keep up, what do you do?". A strong answer checks lag first, then looks at whether the bottleneck is processing or partition count, and notes that adding consumers only helps up to the number of partitions. In production the recurring incidents are rebalance storms from slow processing and offset resets (a group that lost its committed offsets restarting from earliest or latest, depending on auto.offset.reset), so know where that setting is and what it does.

Try it yourself

Extend group_sim.py with a second group named audit that has 2 consumers and the same partitions. Print its assignment and confirm every partition appears exactly once inside each group, but appears in both groups. Then write down what you would check first if lag in dispatch kept growing after you added consumers.

Show a hint

Call assign twice with different member sets. For the lag question, compare consumer count against partition count before touching any timeouts.

Key takeaways

  • Producers batch records in memory; acks, linger.ms and compression trade latency, throughput and safety.
  • Within a group each partition has one consumer, so parallelism is capped by the partition count.
  • A committed offset is the next record to read, stored in __consumer_offsets; commit after processing for side-effecting work.
  • Different groups each get every record; the same group shares the work.
  • Rebalances come from membership changes or a consumer exceeding max.poll.interval.ms; lag trends reveal trouble early.

Test yourself

A trick from this lesson's ride: produce() drops the letter in your outbox; only the receipt (callback or flush) proves delivery. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is a consumer group in Kafka?

A consumer group is a set of consumers that share one group.id and cooperate to read a topic. Kafka assigns each partition to exactly one consumer in the group, so the group processes each record once between its members. Separate groups each receive the full stream independently.

Why are my Kafka consumers idle?

Most often there are more consumers in the group than partitions in the topic, so some get no assignment. Other causes are a different group.id than intended or a rebalance still in progress. Check partition assignments with the kafka-consumer-groups tool before changing code.

What is consumer lag in Kafka?

Lag is the difference between the latest offset in a partition (the log end offset) and the group's committed offset for it. It shows how many records the group has yet to process. A steady small lag is normal; a lag that keeps growing means consumers are slower than producers.