Engineering Notes
Home/Distributed Systems/Message Brokers/Producers and Consumers
Distributed Systems · Message Brokers

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.

🕸️ Distributed Systems● Intermediate⏱ 5 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 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.

ts
// 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:

PartPurpose
keypartitioning / ordering scope (orderId, userId)
event idlets consumers deduplicate redeliveries
type + schema versionconsumers can evolve independently
timestampwhen it happened (not when it was consumed)
payloadsmall; IDs + the facts consumers need, not whole aggregates

2Producer Reliability Knobs

SettingEffect
acks (Kafka) / publisher confirms (RabbitMQ)does send() resolve only after the broker has durably stored it?
retriesresend 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
compressionsmaller 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:

ts
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.

💡 Example: a clean SIGTERM handler
ts
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.

A producer adds a key, event id and schema version, sends with acks and an idempotent sequence number; a consumer processes then commits its offset, with retries ending in a dead-letter queue
A producer adds a key, event id and schema version, sends with acks and an idempotent sequence number; a consumer processes then commits its offset, with retries ending in a dead-letter queue

⚠️ Common Pitfalls

  1. Fire-and-forget sends (acks=0, no publisher confirms) for data that matters.
  2. Retries without idempotence → duplicates; max.in.flight > 1 without idempotence → reordering.
  3. Committing offsets before processing → silent message loss on crash.
  4. Non-idempotent handlers under at-least-once delivery → double charges, double emails.
  5. Slow processing inside the poll loop → the consumer misses max.poll.interval.ms, gets kicked from the group, and triggers rebalances.
  6. Huge payloads — put the blob in object storage and send a reference.

📋 Quick Reference

ProducerConsumer
key = ordering scopeprocess → then ack/commit
acks=all + idempotenceidempotent handler (dedup by event id)
outbox for DB + publishbounded retries → DLQ
batch + compress for throughputprefetch / poll size for flow control
event id, type, schema versiongraceful 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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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).

Follow-ups interviewers ask
  • 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).

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. What does the idempotent producer use to detect retries?
seq ≤ last means already written.
2. Commit before processing gives…
A crash after commit loses the message.
3. Why include an event ID in every message?
At-least-once means duplicates happen.
4. On SIGTERM, a consumer should first…
Then drain, commit and leave the group.