Producers and Consumers
Whether messages get lost, duplicated or reordered is decided by producer and consumer settings. Walk through acks, retries and idempotent producers, then the consumer's process-then-commit loop, flow control and graceful shutdown.
🎯 What you'll learn
- What a good message contains (key, event ID, schema version)
- Producer reliability: acks, retries, idempotence, batching
- Consumer loop: process then ack, push vs pull, scaling
- Graceful shutdown and how Kafka dedupes producer retries
📚 Before you start
1The Producer's Job
A producer turns something that happened into a message and gets it durably into the broker. That sounds trivial; the details decide whether messages get lost, duplicated, or reordered.
// Kafka (kafkajs)
await producer.send({
topic: "orders",
acks: -1, // wait for all in-sync replicas (09-delivery_guarantees/)
messages: [{
key: order.id, // same key → same partition → ordered per order
value: JSON.stringify({ type: "OrderPlaced", orderId: order.id, total: order.total }),
headers: { "event-id": crypto.randomUUID(), "schema-version": "3" },
}],
});A good message has:
| Part | Purpose |
|---|---|
| key | partitioning / ordering scope (orderId, userId) |
| event id | lets consumers deduplicate redeliveries |
| type + schema version | consumers can evolve independently |
| timestamp | when it happened (not when it was consumed) |
| payload | small; IDs + the facts consumers need, not whole aggregates |
2Producer Reliability Knobs
| Setting | Effect |
|---|---|
acks (Kafka) / publisher confirms (RabbitMQ) | does send() resolve only after the broker has durably stored it? |
| retries | resend on transient errors — can duplicate unless the producer is idempotent |
idempotent producer (enable.idempotence=true) | broker drops duplicates using producer id + sequence numbers |
max.in.flight.requests | >1 with retries and no idempotence can reorder messages |
batching (linger.ms, batch.size) | trade a few ms of latency for much higher throughput |
| compression | smaller network/disk footprint for batched payloads |
The dual-write problem applies to producers too: "commit DB, then publish" can lose the event if the process crashes between the two. The transactional outbox (backend/frameworks/16-background_jobs/ §6) fixes it.
3The Consumer's Job
A consumer receives messages, processes them, and tells the broker it's done (ack / commit offset). The order of those last two steps defines the delivery guarantee:
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
await handle(JSON.parse(message.value!.toString())); // 1. process
// 2. offset committed after this function resolves (autoCommit) — at-least-once
},
});- Process, then ack → a crash between them redelivers → at-least-once (handlers must be idempotent).
- Ack, then process → a crash between them loses the message → at-most-once.
09-delivery_guarantees/ covers this in depth.
4Push vs Pull, and Flow Control
- Push (RabbitMQ): the broker sends messages to the consumer as they arrive, limited by prefetch (
basic.qos) — the max number of unacked messages per consumer. Prefetch 1 = fair dispatch but slow; prefetch 1000 = fast but one consumer can hoard work. - Pull (Kafka, SQS): the consumer asks for the next batch when it's ready. Backpressure is natural: a busy consumer simply doesn't poll (
distributed-systems/fundamentals/05-backpressure) — as long as it still polls often enough to stay in the group (max.poll.interval.ms).
5Scaling Consumers
- Competing consumers: run N instances on the same queue / in the same consumer group; throughput scales with N (Kafka: up to the partition count).
- Concurrency inside a consumer: process several messages in parallel — but only across different keys, or you lose per-key ordering (
08-acknowledgements_and_message_ordering/). - Poison messages: a message that always fails must not block the rest forever — retry a bounded number of times, then dead-letter it (
10-dead_letter_queues/).
6Graceful Shutdown
On SIGTERM: stop fetching new messages, finish (or abandon and not ack) in-flight ones, commit offsets for what's done, leave the group cleanly so partitions are reassigned immediately rather than after a session timeout.
process.on("SIGTERM", async () => {
await consumer.pause(consumer.assignment()); // 1. stop fetching
await inFlight.drain(); // 2. finish current messages
await consumer.commitOffsets(processed); // 3. commit what's done
await consumer.disconnect(); // 4. leave the group → fast rebalance
process.exit(0);
});Under the HoodHow Kafka's Idempotent Producer Deduplicates▾
When idempotence is enabled, the broker assigns the producer a producer ID (PID), and the producer numbers every record batch per partition with a monotonically increasing sequence number. The partition leader remembers, per PID, the last few sequence numbers it has appended:
- seq = last + 1 → append (normal case)
- seq ≤ last (a retry of something already written — the ack was lost) → acknowledge without appending again
- seq > last + 1 (a gap — something was lost or reordered) → reject with
OutOfOrderSequence
That's what makes retries safe and keeps order with up to 5 in-flight requests. It only covers duplicates caused by the producer's own retries within one session; a producer that restarts and re-sends from application code gets a new PID — that's what transactional IDs (and consumer-side dedup) are for.
⚠️ Common Pitfalls
- Fire-and-forget sends (
acks=0, no publisher confirms) for data that matters. - Retries without idempotence → duplicates;
max.in.flight > 1without idempotence → reordering. - Committing offsets before processing → silent message loss on crash.
- Non-idempotent handlers under at-least-once delivery → double charges, double emails.
- Slow processing inside the poll loop → the consumer misses
max.poll.interval.ms, gets kicked from the group, and triggers rebalances. - Huge payloads — put the blob in object storage and send a reference.
📋 Quick Reference
| Producer | Consumer |
|---|---|
| key = ordering scope | process → then ack/commit |
acks=all + idempotence | idempotent handler (dedup by event id) |
| outbox for DB + publish | bounded retries → DLQ |
| batch + compress for throughput | prefetch / poll size for flow control |
| event id, type, schema version | graceful shutdown: stop, finish, commit, leave |
🧠 Key takeaways
- Messages carry a key (ordering scope), event ID (dedup), type + schema version, and a small payload.
- Producers: acks=all, retries with an idempotent producer, batching for throughput, and the outbox for DB + publish.
- Consumers: process → ack gives at-least-once, so make handlers idempotent; pull gives natural backpressure.
- Scale up to the partition count; on shutdown stop fetching, drain, commit, leave the group.
🎤 Interview Q&A
Q1What should a reliable producer do?core▾
Wait for broker acknowledgement (publisher confirms / acks=all), retry transient failures with backoff, enable idempotence where available (Kafka) to avoid duplicates from retries, choose message keys deliberately for ordering, and avoid the dual-write problem with an outbox.
- Fire-and-forget? Only for data you can afford to lose, like some metrics.
Q2What should a reliable consumer do?core▾
Process idempotently, acknowledge (or commit offsets) only after successful processing, handle poison messages with limited retries and a DLQ, and shut down gracefully (finish in-flight work, commit, then stop).
- Ack before processing? Gives at-most-once: a crash loses the message.
Q3How do you make consumers idempotent?core▾
Store processed message IDs (or a business key) in a dedupe table, ideally in the same transaction as the side effect; use upserts or conditional updates; design operations to be naturally idempotent (set status = paid, not balance += x).
- How long do you keep dedupe records? At least as long as the broker's maximum redelivery window.
Q4How do you handle slow consumers?senior▾
Scale out consumers (up to partition count in Kafka), batch processing, tune prefetch/poll sizes, move heavy work to separate queues, and monitor consumer lag to scale automatically.
- What is consumer lag? How far the consumer's offset is behind the newest message.
Q5How do you evolve message schemas safely?core▾
Backward-compatible changes only (add optional fields with defaults), consumers ignore unknown fields, a schema registry enforces compatibility, and breaking changes go into a new versioned topic or event type with a migration period.
- Which side upgrades first? Consumers first for new fields they need; producers can then start sending them.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.