TL;DR

1. The problem: why go asynchronous?

When you tap "Share" on a new Instagram post, a surprising amount of work must happen:

If the Upload/Post service called each of these synchronously, one after another, the user would wait for the sum of all their latencies (seconds, or minutes for a celebrity fan-out); if any one were down, posting would fail; and every new feature would require changing the Post service. Instead, the Post service writes the post, publishes a single "post created" event, and returns "posted!" in milliseconds. Everything else happens asynchronously.

Synchronous: latencies add up, failures cascade Asynchronous: publish once, consumers react Post service Media processing 3 s Feed fan-out 0.5–60 s Search indexer 80 ms Notifications ✗ down Analytics 40 ms user waits ≈ sum of all calls; one failure fails the post Post service Kafka topicpost.created Media workers Fan-out workers Search indexer Notification svc Analytics / ML user waits ≈ 1 DB write + 1 publish (~10–20 ms)
Synchronous orchestration couples the user's latency and availability to every downstream system. Publishing one event decouples them.

1.1 What asynchronous messaging buys you

🔌 Decoupling

The producer doesn't know who consumes. Adding a new consumer (say, a spam detector) needs zero changes to the Post service. Teams deploy and scale independently.

🌊 Load levelling / buffering

Producers can burst at 5× normal rate (New Year's Eve at midnight); the queue absorbs it and consumers drain at a steady rate instead of falling over.

🔁 Retries & resilience

If the notification service is down for 10 minutes, its messages wait in the queue and are processed when it recovers. Nothing is lost; the user never noticed.

📣 Fan-out

One event, many independent consumers, each processing at its own speed with its own failure handling.

⚡ Faster responses

The user-facing request does the minimum (write + publish); slow work moves off the critical path.

📜 Replay & audit (logs)

With Kafka you can re-read last week's events to rebuild a search index or backfill a new feature.

The costs: eventual consistency (a follower might see the post a few seconds later; search may lag), more moving parts, harder debugging across services, and new failure modes (duplicates, out-of-order messages, lag). Use async messaging for work that does not need to complete before you answer the user.

🧠 Analogy A busy restaurant. The waiter (producer) doesn't walk into the kitchen and stand next to the chef until the dish is ready — they pin an order ticket on the rail (the queue) and go serve other tables. Cooks (consumers) take tickets at their own pace; at peak, tickets pile up on the rail rather than waiters crashing into the kitchen. A point-to-point queue is the ticket rail: each ticket is cooked by exactly one cook. Pub/sub is more like a newspaper: one edition is printed and every subscriber gets their own copy. Kafka is like a newspaper archive: every edition is kept, and each reader keeps a bookmark of how far they've read — a new reader can start from last week's edition.

2. Core concepts and messaging models

2.1 Point-to-point queues vs publish/subscribe

Point-to-point (work queue) Publish / subscribe Producer Queue Worker 1 Worker 2 Worker 3 Publisher Topic Search indexer Fan-out service Notifications each message goes to ONE worker (competing consumers) each message goes to EVERY subscriber
Queues distribute work; pub/sub broadcasts events. Kafka does both: within one consumer group partitions are divided (queue-like); across groups every group sees everything (pub/sub).
Point-to-point queuePublish/subscribe
DeliveryEach message processed by exactly one of the competing consumersEach subscriber (or subscriber group) receives a copy
PurposeDistribute work (jobs, tasks)Broadcast events to independent systems
ScalingAdd workers to drain fasterAdd subscribers without touching publishers
Instagram example"Transcode this video" jobs spread over a GPU worker pool"Post created" consumed by search, fan-out, notifications, analytics

3. Traditional brokers vs log-based brokers

There are two families of messaging systems, and choosing between them is a frequent interview question.

Traditional broker (RabbitMQ, SQS)Log-based (Kafka, Kinesis, Pulsar)
Storage modelQueue; message deleted after ackAppend-only partitioned log; retained by time/size
Consumer stateBroker tracks per-message delivery/ackConsumer group tracks one offset per partition
ReplayNo (once acked, gone)Yes — rewind offsets, reprocess history
Multiple consumersCopy into multiple queues (fanout exchange / SNS→SQS)Any number of groups read the same log independently
OrderingPer queue, weakened by redelivery/competing consumers (SQS standard: best effort; SQS FIFO: per message group)Strict within a partition
ThroughputTens of thousands msg/s per node (RabbitMQ); SQS scales transparentlyHundreds of thousands to millions msg/s per cluster via sequential disk I/O and batching
Per-message featuresRich routing, priorities, per-message TTL, delays, individual acks/requeueMinimal; per-message retry/delay must be built (retry topics)
ParallelismAdd consumers freelyBounded by number of partitions per group
Best forTask queues, RPC-style jobs, complex routing, low volume with per-message controlHigh-volume event streams, multiple subscribers, event sourcing, CDC, analytics pipelines

For Instagram's event bus — high volume, many independent consumers, replay to rebuild indexes, analytics — Kafka is the natural choice, and that's what our architecture diagram shows. A task-queue system (Celery on RabbitMQ/Redis, or SQS) still fits well for discrete jobs like "send this email".

4. Kafka internals

4.1 Topics, partitions and offsets

A topic is a named stream of related events, e.g. post.created. A topic is split into partitions; each partition is an ordered, append-only log stored on disk. Every message in a partition gets a sequential offset (0, 1, 2, …), its permanent address within that partition.

 topic: post.created          (3 partitions; each is an independent ordered log)

 partition 0 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |  ← producers append here (log end offset = 9)
                               ↑               ↑
                     group "search" at 4       group "fanout" at 8

 partition 1 | 0 | 1 | 2 | 3 | 4 | 5 |
 partition 2 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |

 • Order is guaranteed only WITHIN a partition (p0 offset 3 is before p0 offset 4).
 • Messages are NOT deleted when read; each consumer group keeps its own offset.
 • Old log segments are deleted by retention (e.g. 7 days) or compacted.

Why partitions? A single log can only be as fast as one disk and one machine. Partitions let a topic spread across many brokers (more write throughput) and let many consumers read in parallel (one consumer per partition per group). Kafka is fast because each partition is written and read sequentially (disks and the OS page cache love sequential I/O), messages are batched and compressed, and data is sent from page cache to socket with zero-copy (sendfile).

4.2 Brokers, leaders, followers and the ISR

A Kafka cluster is a set of servers called brokers. Each partition is replicated to replication factor (RF, typically 3) brokers. One replica is the leader — all produce and (by default) consume requests go to it — and the others are followers that continuously fetch from the leader to stay in sync.

Broker 1Broker 2Broker 3 P0 — LEADER P1 — follower (in ISR) P2 — follower (in ISR) P0 — follower (in ISR) P1 — LEADER P2 — follower (lagging) P0 — follower (in ISR) P1 — follower (in ISR) P2 — LEADER Topic with 3 partitions, RF = 3. Leaders are spread across brokers; followers fetch from leaders (dashed: P0 replication). ISR(P2) = {broker 3, broker 1} — broker 2 fell behind and was removed until it catches up.
Partition leadership is balanced across brokers. If broker 1 dies, a controller promotes an in-sync follower of P0 (on broker 2 or 3) to leader.

4.3 acks: how durable is a write?

Producer acksLeader replies when…LatencyRisk
acks=0Never — fire and forgetLowestMessages lost silently if anything fails
acks=1Leader has written it to its logLowLost if the leader dies before followers copy it
acks=all (-1, default since Kafka 3.0)All current ISR members have itHigher (one replication round trip)No loss as long as one ISR member survives

Pair acks=all with min.insync.replicas=2 and RF = 3: a write succeeds only if at least 2 replicas have it; if the ISR shrinks to 1, producers get errors rather than writing data that only one machine holds. That's the standard "don't lose events" configuration for business events like post.created. For high-volume, loss-tolerant telemetry (impressions), acks=1 may be acceptable.

4.4 Retention and log compaction

 Before compaction (key:value)       After compaction
 0  u1:{name:"ana"}                   
 1  u2:{name:"raj"}                   1  u2:{name:"raj"}
 2  u1:{name:"ana_k"}                 
 3  u3:{name:"li"}                    
 4  u1:{name:"ana.k"}                 4  u1:{name:"ana.k"}
 5  u3:null   (tombstone)             5  u3:null   (removed later)

5. Producers and partition keys

The producer decides which partition each message goes to:

Choosing the key is a design decision:

Key choiceOrdering you getRisk
post_id for like/comment eventsAll events about one post in order (like then unlike stays correct)A viral post makes one partition hot
user_id (author) for post eventsA user's posts/deletes in orderCelebrities create hot partitions (fine for low-rate post events)
conversation_id for DMsMessages within a chat in orderLarge group chats are hot
None / randomNo orderingBest balance; use when order doesn't matter (impressions)
⚠️ Changing partition count breaks key → partition mapping Because the mapping is hash mod N, adding partitions to an existing topic sends some keys to different partitions; for a short time, old and new events for the same key live in different partitions and can be processed out of order. Over-provision partitions up front (e.g. 2–3× expected need) and create a new topic for big changes.

Other producer knobs: linger.ms and batch.size (wait a few ms to send bigger batches → much higher throughput), compression.type (lz4/zstd), and enable.idempotence=true (default since 3.0): the broker de-duplicates producer retries using a producer ID + per-partition sequence numbers, so a network retry doesn't create a duplicate in the log.

from confluent_kafka import Producer
p = Producer({"bootstrap.servers": "kafka:9092", "acks": "all",
              "enable.idempotence": True, "linger.ms": 5, "compression.type": "zstd"})

event = PostCreated(post_id=4185136929178974185, author_id=31341, media_keys=[...])
p.produce("post.created", key=str(event.author_id), value=event.SerializeToString(),
          on_delivery=lambda err, msg: err and log.error("publish failed", err))
p.flush()

6. Consumer groups, partition assignment and rebalancing

A consumer group is a set of consumer processes that cooperatively read a topic. Kafka assigns each partition to exactly one consumer in the group, so the group processes every message once (queue semantics), while different groups each read everything (pub/sub semantics).

topic: post.created Partition 0 Partition 1 Partition 2 Partition 3 group: search-indexer Consumer A (P0, P1) Consumer B (P2, P3) group: feed-fanout Worker 1 (P0) Worker 2 (P1) Worker 3 (P2) Worker 4 (P3) Worker 5 — idle (no partition)
Two groups read the same topic independently. Within a group each partition has one owner, so parallelism is capped at the partition count — the 5th fan-out worker sits idle.
from confluent_kafka import Consumer
c = Consumer({"bootstrap.servers": "kafka:9092", "group.id": "search-indexer",
              "enable.auto.commit": False, "auto.offset.reset": "earliest",
              "partition.assignment.strategy": "cooperative-sticky"})
c.subscribe(["post.created"])
while True:
    msg = c.poll(1.0)
    if msg is None or msg.error():
        continue
    index_post(PostCreated.FromString(msg.value()))   # must be idempotent!
    c.commit(message=msg, asynchronous=False)           # commit AFTER processing → at-least-once

7. Delivery semantics

Networks drop packets, consumers crash mid-processing, producers retry. What can a messaging system promise about how many times a message is processed?

SemanticsHow it arisesFailure outcomeUse for
At-most-onceConsumer commits the offset (or acks) before processing; producer doesn't retryCrash after commit, before processing → message lostMetrics/telemetry where loss is acceptable
At-least-onceCommit/ack after processing; producer retries on timeoutCrash after processing, before commit → message processed againThe default for almost everything — with idempotent consumers
Exactly-once (effectively-once)Idempotent producer + transactions, or at-least-once + idempotent/deduplicating processingEach message's effect applied onceCounters, payments, stream processing where duplicates corrupt results
 At-least-once duplicate:
  consumer: poll offset 41 → process (write to Elasticsearch ✔) → CRASH before commit
  restart:  last committed offset = 40 → poll offset 41 AGAIN → process again

Kafka's exactly-once semantics (EOS) combine the idempotent producer (no duplicate writes on retry) with transactions: a stream processor reads from input topics, writes to output topics and commits its consumer offsets atomically in one transaction; downstream consumers with isolation.level=read_committed never see aborted writes. This gives exactly-once for Kafka-to-Kafka pipelines (e.g. Kafka Streams, Flink). As soon as a side effect leaves Kafka — sending a push notification, writing to Redis — you're back to at-least-once, and the consumer must be idempotent.

7.1 Idempotent consumers

An operation is idempotent if doing it twice has the same effect as doing it once. Techniques:

def handle_like(evt):                       # at-least-once delivery, idempotent effect
    with db.transaction() as tx:
        inserted = tx.execute(
            "INSERT INTO likes (post_id, user_id, created_at) VALUES (%s, %s, %s) "
            "ON CONFLICT (post_id, user_id) DO NOTHING", (evt.post_id, evt.user_id, evt.ts)).rowcount
    if inserted:                            # only the first delivery changes the counter
        redis.hincrby(f"post:{evt.post_id}:counts", "likes", 1)
        notify_author_once(evt.event_id)    # notification service dedupes by event_id too

(There is still a tiny window between the DB commit and the Redis increment; counters are periodically reconciled from the likes table — see Likes & Counters.)

8. Dead-letter queues and poison messages

A poison message is one that always fails — malformed JSON, a schema the consumer can't parse, a reference to a deleted user, or one that triggers a bug. In a partitioned log this is particularly nasty: if the consumer keeps retrying, it blocks the whole partition (everything behind it waits). Standard handling:

  1. Classify errors: transient (timeout, 503 from Elasticsearch) → retry; permanent (deserialization error, validation failure) → don't retry forever.
  2. Retry in place a few times with exponential backoff and jitter (see Resilience Patterns).
  3. Retry topics: if still failing, publish the message to post.created.retry.1m, then .retry.10m, consumed by a delayed consumer — the main partition keeps flowing.
  4. Dead-letter queue (DLQ): after N attempts, park the message in post.created.dlq with error details in headers (exception, attempts, original partition/offset), commit the offset and move on.
  5. Alert and redrive: DLQ depth is an alert. Engineers fix the bug, then replay the DLQ back to the main topic.

Trade-off: moving a message aside breaks per-key ordering (later events for that key get processed first). For order-critical streams, either pause the partition and page someone, or make consumers tolerant (versions/timestamps). SQS and RabbitMQ provide DLQs natively (SQS redrive policy with maxReceiveCount; RabbitMQ dead-letter exchanges); in Kafka it's an application pattern supported by frameworks (Spring Kafka, Kafka Connect).

9. Consumer lag and backpressure

Consumer lag = (log end offset) − (group's committed offset), per partition. It is the single most important health metric for an async pipeline: it tells you how far behind real time a consumer is.

Partition 7 of post.created:  log end offset = 1,250,000   committed (feed-fanout) = 1,190,000
lag = 60,000 messages.  Consume rate 2,000 msg/s, produce rate 1,500 msg/s
→ catching up at 500 msg/s → back to real time in 60,000 / 500 = 120 s
Lag in TIME (age of oldest unprocessed message) is what users feel: "followers see posts ~2 min late".

Lag grows when consumers are slower than producers — traffic spike, slow downstream (Elasticsearch GC pauses), a poison message, or a rebalance storm. Responses:

Backpressure is how a system tells upstream "slow down". Kafka consumers pull, so they're never overwhelmed — the backlog simply accumulates in the log (that's the load-levelling buffer, bounded by retention: if lag exceeds retention, data is lost — alert well before!). Producers get backpressure when the broker can't keep up: the client's buffer (buffer.memory) fills and send() blocks or fails, which the service should turn into rate limiting or 503s rather than unbounded memory growth. Push-based brokers (RabbitMQ) use consumer prefetch limits for the same reason.

10. Publishing reliably: the transactional outbox and CDC

The Post service must do two things: write the post to its database and publish post.created to Kafka. These are two different systems with no shared transaction — the dual-write problem:

Transactional outbox: in the same local DB transaction as the business write, insert the event into an outbox table. A separate relay reads the outbox and publishes to Kafka, marking rows as sent. Atomicity comes from the database; delivery is at-least-once (the relay might publish twice), so consumers stay idempotent.

  Post service                      Post DB (one transaction)                 Relay / CDC         Kafka
     | BEGIN                                                                     |                  |
     |-- INSERT INTO posts (...) ------------>|                                  |                  |
     |-- INSERT INTO outbox (event_id, topic, key, payload) -->|                 |                  |
     | COMMIT  (both rows or neither)                                            |                  |
     |                                         | -- commit log / WAL ------->   | (Debezium tails)  |
     |                                                                           |-- publish ------>|
     |                                                                           |   post.created   |

Change Data Capture (CDC) is the best relay: tools like Debezium (running on Kafka Connect) read the database's replication log — PostgreSQL's WAL via logical decoding, MySQL's binlog, Cassandra's commit log/CDC — and emit every committed change as a Kafka event, with no polling and no extra load on tables. Debezium has a built-in "outbox event router" that turns outbox-table inserts into clean domain events on the right topic with the right key. CDC is also how you keep caches, search indexes and data warehouses in sync with a database without the application having to remember to publish (see Caching for CDC-driven cache invalidation).

11. Event schemas and schema evolution

Events outlive the code that produced them: a topic may hold 7 days of messages written by three different versions of the Post service, read by a dozen consumers deployed on their own schedules. Changing an event's shape carelessly breaks consumers at 3 a.m.

// post_created.proto — evolving safely
message PostCreated {
  fixed64 post_id     = 1;
  fixed64 author_id   = 2;
  int64   created_ms  = 3;
  repeated string media_keys = 4;
  string  caption     = 5;
  // v2: added — old consumers ignore unknown field 6; new consumers see "" for old events
  string  location_id = 6;
  // NEVER reuse a removed field's number; mark it reserved instead
  reserved 7;  // was: string filter_name
}

Safe rules of thumb: add optional fields with defaults; never change a field's type or meaning; never reuse field numbers/names; to make big changes, publish a new event version on a new topic (post.created.v2) and run both during migration. Include standard envelope fields in every event: event_id (for dedupe), event_type, occurred_at, producer, schema_version, and a trace ID (see Observability).

12. How Instagram uses message queues

In our architecture diagram, producer services publish domain events to Kafka and five kinds of consumers react: the Search Indexer, Feed Fan-out Workers, Media Processing Workers, the Notification Service, and Analytics & ML Training.

Producer servicesUpload, Stories, Like/Comment, Follow
→domain events
Kafka event bustopics × partitions, RF 3
→consumer groups
Workerssearch, fan-out, media, notify, analytics
→
Elasticsearch · Redis · S3 · APNs/FCM · warehouse
TopicPartition keyProducersConsumers (each its own group)
media.uploadedmedia_idUpload Service (on object-created / finalize)Media Processing Workers (resize, transcode, thumbnails, moderation)
media.processedpost_idMedia Processing WorkersUpload Service (mark post PUBLISHED, then emit post.created)
post.created / post.deletedauthor_idUpload / Post Service (via outbox + CDC)Feed Fan-out Workers (push to followers' Redis timelines; pull path for celebrities), Search Indexer, Notification Service (mentions/tags), Analytics & ML; on delete also media cleanup
story.createdauthor_idStories ServiceFan-out (stories tray), Notification Service, Analytics & ML
like.created / comment.createdpost_idLike & Comment ServiceNotification Service ("ana liked your photo", batched), counter aggregation, Analytics & ML (engagement signals for ranking)
follow.created / follow.deletedfollower_idFollow / Graph ServiceFeed (backfill or remove the followee's recent posts), Notification Service ("X started following you"), Search (people suggestions), Analytics & ML
user.updated (compacted)user_idUser / Auth ServiceSearch Indexer (username, display name), cache invalidation
client.events (impressions, views)none / user_idApps via a logging endpointAnalytics & ML Training (Spark / warehouse) — often a separate, high-volume cluster
📸 In Instagram Instagram's early public "What Powers Instagram" post described using Gearman as a task queue for work like fan-out and notifications, and later public talks mentioned Celery with RabbitMQ for asynchronous tasks in the Django stack. Today Instagram runs on Meta's in-house infrastructure, which has its own publicly described logging and streaming systems (e.g. Scribe for log transport and LogDevice for durable distributed logs). In a design interview, Kafka is the standard, well-understood stand-in for this event bus, and the important points are the ones in this lesson: per-key ordering, consumer groups per downstream system, at-least-once with idempotent consumers, outbox/CDC for reliable publishing, and lag monitoring.

Why author_id as the key for post events? It keeps a user's create/edit/delete events in order (a delete is never processed before its create). Post events are low-rate even for celebrities, so hot partitions aren't a concern. For likes, post_id keeps like/unlike for a post ordered; a viral post can make one partition hot, so the like consumer aggregates in memory and writes batched counter updates.

12.1 Sizing example

All inputs are assumptions for practice (see Back-of-the-Envelope Estimation):

Domain events per day (assume):
  likes 1B + comments 100M + posts 100M + stories 300M + follows 50M + misc 450M ≈ 2B events/day
  average size (Protobuf + envelope) ≈ 1 KB

Throughput:
  2B / 86,400 s ≈ 23K msg/s ≈ 23 MB/s average;  peak ×3 ≈ 70K msg/s ≈ 70 MB/s

Storage (7-day retention, replication factor 3):
  2 TB/day × 7 × 3 ≈ 42 TB  (plus ~30% headroom → ~55 TB)

Broker network/disk:
  writes: 70 MB/s × 3 replicas = 210 MB/s cluster-wide at peak
  reads:  ~5 consumer groups × 70 MB/s = 350 MB/s (mostly from page cache)
  → ~9 brokers with ~6 TB each gives comfortable headroom (a broker can sustain hundreds of MB/s)

Partitions (driven by consumer parallelism, not bytes):
  post.created peaks at only ~3.5K posts/s, but each post fans out to hundreds of timelines;
  assume one fan-out worker handles 50 posts/s → 70 workers → give the topic 96–128 partitions.
  like.created at ~35K/s peak, 2,000/s per consumer → ≥ 18 → choose 64 for headroom and hot-key spread.

Separate cluster for client.events: tens of billions of impressions/day → 100s of MB/s to GB/s.

13. Trade-offs & common pitfalls

✅ Benefits

  • Fast user-facing writes; slow work off the critical path
  • Services decoupled in code, deployment, scaling and failure
  • Absorbs spikes; downstream outages don't lose work
  • Cheap fan-out to any number of consumers
  • Replay to rebuild indexes, backfill features, debug

⚠️ Costs

  • Eventual consistency — users may briefly see stale results
  • Duplicates and reordering must be handled by consumers
  • Operating Kafka (brokers, partitions, upgrades) is real work
  • Harder debugging across async hops — need tracing
  • Schema governance across teams

Common pitfalls

14. Real-world technologies

TechnologyModelWhen to use
Apache Kafka (Confluent, Amazon MSK)Partitioned, replicated logHigh-volume event streams, many consumers, replay, CDC, stream processing
RedpandaKafka-API-compatible log in C++, no JVMKafka semantics with lower latency/ops overhead
Apache PulsarLog with separate storage (BookKeeper); queues + streamsMulti-tenancy, geo-replication, very many topics
Amazon Kinesis Data StreamsManaged sharded logAWS-native streaming without running Kafka
RabbitMQAMQP broker: exchanges, queues, acksTask queues, complex routing, per-message priorities/TTL
Amazon SQS (+ SNS for fan-out)Managed queue (standard / FIFO), visibility timeout, DLQSimple, serverless job queues; SNS → many SQS queues for pub/sub
Google Pub/Sub, Azure Service Bus / Event HubsManaged pub/sub and logsCloud-native equivalents on GCP/Azure
Redis Streams, NATS JetStreamLightweight streams/queuesLow-latency messaging, smaller scale, simple ops
Celery, Sidekiq, BullMQTask-queue frameworks on RabbitMQ/RedisBackground jobs in Python/Ruby/Node apps
Debezium (Kafka Connect)CDC from database logs to KafkaOutbox relay, syncing search/caches/warehouse from DBs
Confluent Schema Registry, ApicurioSchema storage + compatibility checksGoverning Avro/Protobuf/JSON Schema events

15. Interview corner

Why use Kafka rather than RabbitMQ for Instagram's event bus?

The bus carries billions of events per day consumed by many independent systems (fan-out, search, notifications, analytics, ML). Kafka's partitioned log gives very high throughput through sequential I/O and batching, lets any number of consumer groups read the same events independently, retains events so we can replay to rebuild an index or backfill a new consumer, and guarantees ordering per key. RabbitMQ shines for task queues with rich routing and per-message acks/priorities but deletes messages once consumed and would require duplicating messages into a queue per consumer. Discrete jobs (send an email) could still use a task queue.

How do you ensure a "like" followed by an "unlike" on the same post is processed in the right order?

Key both events by post_id (or (post_id, user_id)), so they go to the same partition, which is strictly ordered, and are processed by a single consumer in that group sequentially. Additionally make processing version-aware (each event carries a timestamp/version; apply only if newer than stored state), which protects against retries and DLQ redrives that could reorder.

The Post service writes to Cassandra and then publishes to Kafka. What can go wrong and how do you fix it?

Dual-write failure: a crash between the two leaves a post that's never fanned out/indexed, or publishing first can announce a post that failed to save. Use the transactional outbox: write the post and an outbox record atomically, and have a relay (ideally CDC such as Debezium reading the commit log) publish it to Kafka. Delivery is at-least-once, so consumers dedupe by event_id or are naturally idempotent. (With Cassandra, a logged batch to both tables or Cassandra CDC can play the outbox role.)

Explain at-least-once vs exactly-once and how you'd make the fan-out worker safe.

At-least-once: commit offsets after processing, so crashes cause redelivery and duplicates but no loss. Exactly-once in Kafka uses idempotent producers plus transactions to atomically write outputs and commit offsets, but only within Kafka. The fan-out worker writes to Redis, so it's at-least-once; it's made idempotent by using sorted-set ZADD with the post ID as member (re-adding is a no-op) and bounded trimming, so redelivery doesn't create duplicate feed entries.

Consumer lag on the search-indexer group is growing. Walk through diagnosis and fixes.

Check whether lag is on all partitions (capacity problem: produce rate up, or Elasticsearch slow — look at bulk latency, rejections, GC) or one partition (hot key or poison message — check that consumer's errors/retries). Look for rebalance storms (poll interval exceeded). Fixes: scale consumers up to partition count; increase bulk batch size; fix/DLQ poison messages; add Elasticsearch capacity; temporarily lower priority work. Long-term: more partitions (new topic), better key distribution, autoscaling on lag, and alerts on lag age well below retention.

How do you choose the number of partitions for a topic?

Take the max of (peak throughput ÷ per-partition throughput, typically ~5–10+ MB/s) and (peak message rate ÷ throughput of one consumer instance) — consumer parallelism is usually the binding constraint. Add headroom (2–3×) because increasing partitions later changes key mapping. Don't go wild: each partition costs broker memory, file handles and leader-election time; thousands per cluster is fine, millions is not.

How do you evolve an event schema without breaking consumers?

Use Avro/Protobuf with a schema registry enforcing a compatibility mode (usually backward or full). Only add optional fields with defaults, never change types or reuse field numbers, and deprecate fields by reserving them. Upgrade consumers before producers under backward compatibility. For breaking changes, publish to a new versioned topic and dual-publish during migration. Every event carries an envelope with event_id, type, version and timestamps.

16. Check your understanding

A topic has 12 partitions. The fan-out consumer group runs 20 instances. How many are doing work? What if a second group with 3 instances subscribes?

Only 12 of the 20 get partitions; 8 sit idle as hot standbys. The second group independently receives all messages, with its 3 consumers each owning 4 partitions. Groups don't affect each other.

Producer uses acks=1. The leader acknowledges a message and crashes before followers copy it. What happens?

A follower becomes leader without that message — it's lost, although the producer was told it succeeded. With acks=all and min.insync.replicas=2, the ack would only come after at least two replicas had it.

Your consumer commits offsets automatically every 5 s, and processes messages in a background thread pool. It crashes. What semantics do you have?

Potentially at-most-once for some messages (offsets committed for messages not yet processed by the pool → lost) and at-least-once for others. To get at-least-once, commit only offsets whose messages (and all before them in that partition) are fully processed.

Log end offset = 5,000,000; committed = 4,400,000; produce 3,000 msg/s; consume 4,000 msg/s. How long until caught up?

Lag 600,000; net drain 1,000 msg/s → 600 s (10 minutes). If the produce rate rises above 4,000 msg/s, lag grows without bound until consumers scale out.

Which topic should use log compaction: like.created, user.profile, client.events?

user.profile — only the latest state per user matters, and new services can bootstrap from it. Likes and client events are facts/streams where every event matters (or only recent history), so they use time-based retention.

Why should a fan-out event carry the post ID and media keys rather than the image bytes?

Kafka is optimised for small messages (default max ~1 MB); multi-MB blobs hurt throughput, replication and page cache for everyone. The bytes already live in object storage; events carry references (the "claim check" pattern).

A message fails deserialization every time. With no DLQ, what happens to the partition, and what should happen?

The consumer retries forever (or crashes and restarts at the same offset), blocking every later message in that partition — lag grows for that partition only. It should be recognised as a permanent error, sent to a DLQ with error metadata, the offset committed, and an alert raised.

🎯 Key takeaways

Related lessons