Hard

Design a Distributed Message Queue (Kafka)

Design a Kafka-style distributed log: partitioned append-only storage, consumer groups with offset tracking, leader-follower replication with ISR, and the tradeoffs behind at-least-once versus exactly-once delivery and ordering.

1Requirements

Functional

  • Producers publish messages to named topics; consumers subscribe and process them
  • Topics are split into partitions; messages with the same key preserve relative order
  • Multiple consumer groups each independently consume the full stream at their own pace
  • Consumers can replay from any retained offset (retention by time or size, e.g. 7 days)
  • Acknowledged messages survive the failure of any single broker

Non-functional

  • Sustain 1M messages/sec (1 GB/sec at 1 KB average) across the cluster
  • End-to-end p99 latency under 50ms for acks=all producers
  • No acknowledged message is lost as long as one in-sync replica survives
  • Horizontally scalable by adding brokers and partitions without downtime
  • Consumers scale to hundreds of instances per group with automatic partition rebalancing

2Back-of-envelope estimation

Throughput1M msg/s x 1 KB = 1 GB/s ingress
Retention storage1 GB/s x 604,800s x 3 = ~1.8 PB for 7 days
Brokers~30 brokers
Partitions600 partitions for the big topic
Consumer group size limitmax 600 parallel consumers

3API design

POST /topics/{topic}/produce

Publish batch: {key, value, headers}[] with acks=0|1|all; returns per-record (partition, offset).

POST /consumer-groups/{group}/poll

Long-poll fetch from assigned partitions starting at current offsets; returns records and high-water marks.

POST /consumer-groups/{group}/commit

Commit consumed offsets per partition; defines the resume point after crash or rebalance.

PUT /topics/{topic}

Create or alter topic: partition count, replication factor, retention.ms.

GET /topics/{topic}/offsets?ts=...

Look up the earliest offset at or after a timestamp, for replay from a point in time.

4High-level design

Storage is an append-only log per partition, physically a sequence of segment files (say 1 GB each) with two sidecar indexes: offset to file position, and timestamp to offset, both sparse (an entry every 4 KB). Appends are sequential writes and reads are sequential scans from an index-located start, which is why a disk-backed log can outrun many in-memory systems: the OS page cache plus sendfile (zero-copy) means hot consumers are served from memory without the JVM touching the bytes.

A topic's partitions are spread across brokers. Producers hash the message key to pick a partition (same key, same partition, hence per-key ordering) or round-robin when keyless. Each partition has one leader broker handling all reads and writes, and N-1 followers replicating by fetching from the leader like ordinary consumers.

Replication safety hinges on the ISR (in-sync replica set): followers caught up within a lag bound. With acks=all, the leader acks a produce only after every ISR member has the record; the high-water mark (minimum replicated offset across ISR) bounds what consumers may read, so a consumer can never see a record that a leader failover could erase. min.insync.replicas=2 with RF=3 means writes stall rather than silently lose redundancy when two replicas are down: choosing consistency over availability for acked data.

Consumer groups deliver queue semantics on top of the log: each partition is assigned to exactly one consumer in the group, and a group coordinator (a broker) manages membership via heartbeats and triggers rebalances when consumers join or die. Progress is just a committed offset per (group, partition), stored in an internal compacted topic. This makes consumption stateless and replayable: reset the offset and history replays; a slow consumer holds back only its own group.

Cluster metadata (which broker leads which partition, ISR membership) lives in a Raft-based controller quorum (KRaft in modern Kafka, ZooKeeper historically). On leader failure the controller elects a new leader from the ISR; any log entries beyond the new leader's high-water mark are truncated on the old leader when it returns, which is exactly why unacked (sub-ISR) writes are the only thing that can be lost.

5Data model

log segment

base_offset, records (offset, timestamp, key, value, headers, crc), sealed_flag

immutable once sealed; deletion is dropping whole old segments

offset index (per segment)

relative_offset, byte_position

sparse, memory-mapped; binary search then short scan

partition metadata

topic, partition_id, leader_broker, replica_set, isr_set, leader_epoch, high_water_mark

leader_epoch fences zombie leaders

consumer_offsets (compacted topic)

group_id, topic, partition, committed_offset, metadata, commit_ts

key = (group, topic, partition); compaction keeps only the latest

6Deep dives

Delivery guarantees: where messages are actually lost or duplicated

At-most-once, at-least-once, and exactly-once are not modes you toggle; they emerge from choices at three points. Producer side: if a produce times out and you retry, the broker may have both copies (duplicate); if you do not retry, it may have neither (loss). Kafka's idempotent producer fixes the duplicate case with a producer id and per-partition sequence numbers the broker uses to discard retried batches. Broker side: acks=1 can lose a record if the leader dies after acking but before followers fetch; acks=all with min ISR closes that hole. Consumer side: commit offsets before processing and a crash skips messages (at-most-once); process then commit and a crash reprocesses (at-least-once).

Exactly-once within the Kafka-to-Kafka world is real: transactions let a consumer-transformer-producer commit output records and input offsets atomically, so a crash either replays into an aborted (invisible) transaction or resumes past a committed one. But the moment effects leave Kafka (an email, an HTTP call, a non-transactional DB write), you are back to at-least-once plus idempotent effects, the same pattern as every distributed system.

Interview framing: say 'at-least-once with idempotent consumers is the default I design for; exactly-once is a property of a closed transactional loop, not of the network'. Then show where each duplicate or loss would concretely occur.

Replication, ISR, and the unclean election tradeoff

Kafka's ISR design is a middle path between synchronous replication to all replicas (slow, one dead follower blocks writes) and fully async (fast, loses acked data). The leader tracks which followers are caught up; only those count for acks=all, and a lagging follower is evicted from the ISR rather than allowed to stall producers. This gives quorum-like durability with the flexibility that the quorum shrinks under failure instead of blocking, down to min.insync.replicas.

The leader_epoch is the subtle piece: it is a monotonically increasing number bumped on every leader election, stamped into the log. A partitioned old leader (a zombie) that keeps accepting writes will have them truncated when it rejoins and discovers a higher epoch, and followers use epoch history to truncate divergent suffixes correctly rather than trusting the high-water mark alone (which historically caused data loss bugs).

Unclean leader election is the tradeoff every candidate should name: if all ISR members die and only a stale replica survives, do you elect it (availability, but acked messages vanish) or wait (consistency, but the partition is down)? Kafka defaults to waiting. Being able to say 'this knob is CAP made concrete, and for a payments topic I would never enable unclean election' is exactly what a hard-level interview is probing.

Consumer groups, rebalancing, and offset management

The consumer group protocol turns a log into a scalable queue. The coordinator assigns each partition to exactly one group member; adding consumers up to the partition count adds parallelism, beyond it they idle. The classic operational pain is rebalancing: eager rebalancing stops the world (every consumer revokes everything, waits, gets a new assignment), so a single deploy of a 200-instance consumer fleet used to cause minutes of pause. Cooperative incremental rebalancing fixes this by only moving the partitions that actually change hands, and static membership (group.instance.id) avoids rebalances entirely on rolling restarts.

Offsets deserve their own paragraph because they are the entire consumption state. Committing to a compacted internal topic means offset commits are themselves just produced messages: replicated, ordered, cheap. Auto-commit on a timer is the classic footgun: it can commit offsets for messages your handler has not finished, silently converting your at-least-once pipeline to at-most-once. Commit manually after processing, and make handlers idempotent because rebalances redeliver in-flight messages.

Also know the poison-pill pattern: a message that always crashes the handler will loop forever under at-least-once. Production systems add a retry counter (in headers) and route to a dead-letter topic after N failures, keeping the partition flowing.

Ordering: what is guaranteed and what people wrongly assume

Kafka guarantees order within a partition, full stop. Cross-partition order does not exist, and 'topic order' is not a thing. Per-entity ordering (all events for user 42 in order) is achieved by keying on the entity id so they land in one partition. This is usually exactly what applications need, and it is why choosing the partition key is the most important schema decision in the system.

Three things silently break even per-key ordering. Producer retries without idempotence: with max.in.flight > 1, batch B can succeed while earlier batch A retries, landing A after B; the idempotent producer restores order for up to 5 in-flight batches. Repartitioning: changing the partition count changes hash(key) mod N, so the same key maps to a new partition and old and new events for one key live in two partitions with no mutual order; plan partition counts generously up front. Consumer-side parallelism: handing records from one partition to a worker pool reorders them; if you parallelize, do it per key, not per record.

The honest summary for an interviewer: ordering is a per-partition, per-key contract that both producer config and consumer architecture must actively preserve, not a global property you get for free.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Node + TypeScript: append-only segment files with a sparse offset index, HTTP produce/consume API, offsets in SQLite; runs anywhere

  1. 01Define the record format: 4-byte length prefix + JSON {offset, ts, key, value}; create a data dir per topic-partition
  2. 02Implement the segment writer: append records, fsync on a 10ms timer, roll to a new segment file at 64 MB
  3. 03Build the sparse index: every 4 KB written, record (offset, bytePosition) in a .index file; loading it enables binary-search seeks
  4. 04Implement produce: hash(key) mod partitions to pick the partition, append, return the assigned offset
  5. 05Implement fetch: given (partition, offset), binary-search the index, scan to the exact record, stream up to max_bytes
  6. 06Add consumer groups: a groups table in SQLite mapping (group, partition) to committed_offset, plus a commit endpoint
  7. 07Add naive rebalancing: consumers heartbeat; on membership change, reassign partitions round-robin and bump a generation id that fences stale commits
  8. 08Verify: produce 1M records, kill -9 the server mid-produce, restart, assert no acked offset is missing and replays from offset 0 return identical data

Append-only segment writer with sparse offset index

typescript
import * as fs from "fs";
import * as path from "path";

const INDEX_INTERVAL_BYTES = 4096;

export class Segment {
  private fd: number;
  private indexFd: number;
  private bytesSinceIndex = 0;
  public bytesWritten = 0;

  constructor(dir: string, public baseOffset: number, public nextOffset: number) {
    const name = String(baseOffset).padStart(20, "0");
    this.fd = fs.openSync(path.join(dir, name + ".log"), "a");
    this.indexFd = fs.openSync(path.join(dir, name + ".index"), "a");
  }

  append(key: string | null, value: string, ts = Date.now()): number {
    const offset = this.nextOffset++;
    const payload = Buffer.from(JSON.stringify({ offset, ts, key, value }));
    const frame = Buffer.alloc(4 + payload.length);
    frame.writeUInt32BE(payload.length, 0);
    payload.copy(frame, 4);

    if (this.bytesSinceIndex >= INDEX_INTERVAL_BYTES) {
      const entry = Buffer.alloc(12);
      entry.writeUInt32BE(offset - this.baseOffset, 0); // relative offset
      entry.writeBigUInt64BE(BigInt(this.bytesWritten), 4); // byte position
      fs.writeSync(this.indexFd, entry);
      this.bytesSinceIndex = 0;
    }
    fs.writeSync(this.fd, frame);
    this.bytesWritten += frame.length;
    this.bytesSinceIndex += frame.length;
    return offset;
  }

  flush() { fs.fsyncSync(this.fd); } // called on a 10ms timer: group commit
}

Fetch by offset using the sparse index

typescript
import * as fs from "fs";

interface IndexEntry { relOffset: number; pos: number }

export function loadIndex(indexPath: string): IndexEntry[] {
  const buf = fs.readFileSync(indexPath);
  const entries: IndexEntry[] = [];
  for (let i = 0; i + 12 <= buf.length; i += 12) {
    entries.push({ relOffset: buf.readUInt32BE(i), pos: Number(buf.readBigUInt64BE(i + 4)) });
  }
  return entries;
}

// Binary search the sparse index for the greatest entry <= target,
// then scan forward frame by frame to the exact offset.
export function fetch(logPath: string, index: IndexEntry[], baseOffset: number,
                      targetOffset: number, maxRecords: number) {
  const rel = targetOffset - baseOffset;
  let lo = 0, hi = index.length - 1, startPos = 0;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    if (index[mid].relOffset <= rel) { startPos = index[mid].pos; lo = mid + 1; }
    else hi = mid - 1;
  }
  const buf = fs.readFileSync(logPath); // real impl: bounded pread, not whole file
  const out = [];
  let pos = startPos;
  while (pos + 4 <= buf.length && out.length < maxRecords) {
    const len = buf.readUInt32BE(pos);
    const rec = JSON.parse(buf.subarray(pos + 4, pos + 4 + len).toString());
    if (rec.offset >= targetOffset) out.push(rec);
    pos += 4 + len;
  }
  return out;
}

Consumer group offsets with generation fencing

sql
CREATE TABLE group_offsets (
  group_id   TEXT NOT NULL,
  topic      TEXT NOT NULL,
  partition  INTEGER NOT NULL,
  committed  BIGINT NOT NULL,       -- next offset to read
  generation INTEGER NOT NULL,      -- bumped on every rebalance
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (group_id, topic, partition)
);

-- Commit is fenced: a consumer from an old generation (kicked out by a
-- rebalance it has not noticed yet) cannot clobber the new owner's progress.
UPDATE group_offsets
SET committed = :offset, generation = :gen, updated_at = datetime('now')
WHERE group_id = :group AND topic = :topic AND partition = :partition
  AND generation <= :gen
  AND committed < :offset;          -- offsets only move forward

-- Resume point after restart or rebalance:
SELECT partition, committed FROM group_offsets
WHERE group_id = :group AND topic = :topic;

Bottlenecks & failure modes

  • Hot partitions from skewed keys (one celebrity user) cap throughput at one broker; salt hot keys or accept per-key ordering loss for whales
  • Partition count explosion: too many partitions inflate metadata, leader elections, and end-to-end latency; too few cap consumer parallelism
  • Stop-the-world rebalances on large consumer groups during deploys; use cooperative rebalancing and static membership
  • Slow ISR follower degrading acks=all latency for all producers on that partition; lag-based ISR eviction trades durability margin for latency
  • Page cache pollution from a lagging consumer reading old segments, evicting hot data and hurting realtime consumers on the same broker

Key takeaways

  • A partitioned append-only log with sequential IO and zero-copy reads is the whole performance story
  • ISR replication plus acks=all plus min.insync.replicas defines exactly which failures can lose acked data
  • Consumer groups turn a log into a queue; offsets in a compacted topic make consumption stateless and replayable
  • Ordering is per-partition only; the partition key is the most consequential design decision
  • Design for at-least-once with idempotent consumers; exactly-once only holds inside a closed transactional loop

Brush up on the underlying topics