← Home
Apache Kafka

Top 8 Senior Interview Questions

Architecture · Delivery Guarantees · Ordering · Rebalancing · Patterns · Real-World Design

Questions

  1. Kafka architecture — brokers, topics, partitions, consumer groups
  2. Delivery guarantees — at-most-once, at-least-once, exactly-once
  3. Consumer group rebalancing — impact and mitigation
  4. Partition key selection — ordering and hot partitions
  5. Offset management — manual vs auto commit
  6. Outbox pattern — guaranteed event publishing
  7. Dead Letter Topics — handling poison pills
  8. Kafka Streams vs Consumer API
Q1 · Architecture
Explain Kafka's architecture — brokers, topics, partitions, consumer groups, replication.

Building blocks

  • Broker: a Kafka server process. A cluster has 3–dozens of brokers. Each broker owns a subset of partition leaders.
  • Topic: named, append-only log of events (e.g. order-events). Data is never modified in place — only appended. Retention is time- or size-based, not read-based.
  • Partition: a topic is split into N partitions. Each partition is an independent ordered log stored as segment files on disk. Partition = unit of parallelism + unit of ordering.
  • Offset: 64-bit integer, monotonically increasing per partition. offset=0 is the oldest message. Consumers track their own offset per partition — Kafka itself does not track "what each consumer has read."
  • Consumer Group: a named group of consumer instances. Kafka assigns each partition to exactly one consumer in the group. Multiple groups can each read the full topic independently (pub/sub model).
  • Replication: each partition has one leader replica + N follower replicas on different brokers. All writes/reads go through the leader. Followers fetch and replicate. ISR (In-Sync Replicas) = followers that are caught up within replica.lag.time.max.ms.

How a write actually works — step by step

Producer                     Broker (leader, partition 0)        Followers (ISR)
  │                                    │                               │
  │── ProduceRequest ─────────────────►│                               │
  │   (batch of records, acks=all)     │── Fetch (replicate) ─────────►│
  │                                    │◄─ Ack (follower caught up) ───│
  │                                    │── append to partition log      │
  │◄── ProduceResponse (offset=1042) ──│                               │
  │   (only sent after all ISR ack)    │                               │
  • Records are batched by the producer (linger.ms / batch.size) before sending — reduces TCP round trips dramatically.
  • The broker appends to a segment file on disk (sequential I/O — fast even on spinning disks).
  • With acks=all, the leader waits until all ISR replicas have written to their local log before acknowledging.
  • Consumers read from the leader's log using a FetchRequest — they supply the partition and starting offset.

Partition parallelism — why it matters

Topic: order-events  (4 partitions, replication-factor=3)

Broker 1: leader P0, follower P2, follower P3
Broker 2: leader P1, follower P0, follower P3
Broker 3: leader P2, follower P1, follower P0
Broker 4: leader P3, follower P1, follower P2
  (spread leaders across brokers for even load)

Consumer Group "order-service" (3 consumers):
  Consumer A → P0, P1   (one consumer can hold multiple partitions)
  Consumer B → P2
  Consumer C → P3

Consumer Group "audit-service" (2 consumers):
  Consumer X → P0, P1, P2   (reads same data independently)
  Consumer Y → P3

Consumer Group "analytics" (6 consumers):
  Consumers 1–4 → P0–P3
  Consumers 5–6 → IDLE ← adding consumers beyond partition count = waste
Key rule: Max useful parallelism = number of partitions. Plan partition count at topic creation — it's hard to increase later (key routing changes).

Replication, ISR, and durability in depth

  • ISR (In-Sync Replicas): a dynamic set. A follower is removed from ISR if it falls behind by more than replica.lag.time.max.ms (default 30s). A lagging follower won't block producer acks.
  • acks=all: producer waits for all current ISR members to write. If ISR shrinks to 1 (just the leader), acks=all still succeeds with just 1 — which is risky.
  • min.insync.replicas=2: broker rejects produce requests if ISR has fewer than 2. This is the real durability guarantee — without it, ISR shrinkage silently weakens acks=all.
  • unclean.leader.election=false: prevents electing an out-of-sync replica as leader. An unclean election avoids downtime but may lose messages the old leader committed.
# Recommended production settings for durability:
acks=all
min.insync.replicas=2            # broker config per topic
replication.factor=3             # at topic creation
unclean.leader.election.enable=false

KRaft vs ZooKeeper — what changed and why it matters

Before KRaft, Kafka relied on ZooKeeper as an external metadata store: controller election, ISR changes, topic configs, and ACLs all lived in ZooKeeper znodes. Problems: operationally complex (two systems to maintain), controller re-election took 10–30 seconds on failure, practical partition limit ~200k.

KRaft (Kafka Raft, GA Kafka 3.3+) makes Kafka self-sufficient:

  • A subset of brokers act as controllers running a Raft quorum. One is the active controller.
  • All cluster metadata lives in a special metadata log (a Kafka topic itself: @metadata).
  • The active controller pushes incremental metadata updates to brokers via MetadataFetch — no more ZooKeeper watch storms on large clusters.
  • Controller failover via Raft: ~200ms vs 10–30s. Scales to millions of partitions.
Interview drill-down — expect these follow-ups:
L1 → "What is the difference between ZooKeeper mode and KRaft mode in Kafka 3.x?"
ZooKeeper was Kafka's external metadata store — controller election, ISR tracking, topic configs all lived there. KRaft (Kafka Raft, GA in Kafka 3.3) makes Kafka self-managing: a subset of brokers (or dedicated controllers) run a Raft-based log for metadata. Benefits: faster leader election (~200ms vs several seconds), no separate ZooKeeper cluster to operate, and scales to millions of partitions (ZooKeeper had a practical ceiling around 200k).
L2 → "How does Kafka leader election work in KRaft — what is the active controller?"
In KRaft, controller nodes form a quorum. One is the active controller; others are followers. The active controller holds the metadata log and pushes incremental updates to brokers via MetadataFetch. If the active controller fails, the quorum elects a new leader from the followers using Raft — no ZooKeeper session timeout needed, so election is faster and more deterministic.
L3 → "You have a 6-broker cluster — how many controller nodes do you deploy in KRaft mode, and why?"
Typically 3 dedicated controller nodes (not broker nodes) — gives you a fault-tolerant quorum that can survive 1 failure (majority = 2 of 3). You could run controllers co-located with brokers for smaller clusters, but separation is recommended for production to prevent broker GC pauses from affecting metadata responsiveness. Never deploy an even number of controllers — split-brain risk.
Trap answer to avoid: "KRaft just removes ZooKeeper — the rest is the same." — KRaft fundamentally changes how metadata is managed and propagated. The active controller now pushes metadata to all brokers; brokers no longer fetch from ZooKeeper. This also changes partition reassignment, rolling upgrades, and tooling (kafka-topics.sh now goes through the broker API, not ZooKeeper directly).
Q2 · Delivery Semantics
Explain at-most-once, at-least-once, and exactly-once delivery. Which do you use in production?

Three semantics — what they mean and how they're achieved

SemanticProducer configConsumer commitRiskWhen to use
At-most-onceacks=0Commit before processingMessage loss on crashMetrics, logs where loss is tolerable
At-least-onceacks=all + retriesCommit after processingDuplicates on retryMost production systems + idempotent consumers
Exactly-once (EOS)Idempotent + transactionsAtomic with produce~4% throughput overhead, complexityCritical Kafka→Kafka pipelines (financial, ledger)

Why at-least-once is the default choice

The failure scenario for at-most-once is: producer sends, network drops the ack, producer does NOT retry → message lost. For most business events, losing data is worse than processing it twice. At-least-once accepts duplicates and pushes deduplication responsibility to the consumer — which you control.

EOS adds real operational weight: transactions need a coordinator, consumers must set isolation.level=read_committed, and any external system write (DB, REST API) falls outside the transaction boundary anyway.

Idempotent producer — how it works internally

props.put("enable.idempotence", "true");
// Automatically sets: acks=all, retries=MAX_INT, max.in.flight=5

// Kafka assigns each producer session a PID (Producer ID).
// Each message gets a sequence number per (PID, partition).
// Broker tracks: last accepted sequence per (PID, partition).
//
// On retry:
//   producer resends with same PID + sequence number
//   broker sees: "I already have sequence 42 for this PID+partition"
//   broker deduplicates → no duplicate written to log
//
// Limitation: PID is per session. If producer restarts → new PID
// → sequence counter resets → broker can't deduplicate across restarts.

Transactional API — how it achieves atomic multi-partition writes

props.put("transactional.id", "order-processor-1"); // stable across restarts
producer.initTransactions(); // registers with transaction coordinator broker

try {
    producer.beginTransaction();

    // Writes to multiple partitions/topics atomically:
    producer.send(new ProducerRecord("debit-events", debitMsg));
    producer.send(new ProducerRecord("credit-events", creditMsg));

    // Atomically commits the consumer offset AND the produced messages.
    // This is the read-process-write atomic unit:
    producer.sendOffsetsToTransaction(currentOffsets, groupMetadata);

    producer.commitTransaction(); // two-phase commit via coordinator
} catch (Exception e) {
    producer.abortTransaction(); // marks records as aborted in log
}

// What happens under the hood:
// 1. beginTransaction: coordinator creates txn record in __transaction_state topic
// 2. Each produce: broker marks records as "transactional, pending"
// 3. commitTransaction: coordinator writes COMMIT marker to all involved partitions
// 4. Consumers with isolation.level=read_committed skip records until COMMIT marker

Consumer side: read_committed

props.put("isolation.level", "read_committed");
// Consumer buffers records until the transaction COMMIT/ABORT marker arrives.
// Aborted records are discarded — never delivered to consumer.poll().
// Side effect: slightly higher end-to-end latency (waits for commit marker).
// Default is read_uncommitted — consumer sees all records including aborted ones.

The critical EOS boundary — external systems are outside

EOS only covers Kafka-to-Kafka. If your consumer reads from Kafka and writes to a database, REST API, or any external system — that write is NOT inside the Kafka transaction. The consumer may commit the Kafka offset (transaction committed) and then crash before the DB write. On restart, the offset is already committed → event is skipped → data loss in the DB. EOS does not protect you here. You still need idempotent consumers for all external writes.

Making a consumer idempotent — the practical pattern

@Transactional
public void processOrder(OrderEvent event) {
    // 1. Stable deduplication key — topic+partition+offset is naturally unique
    String dedupKey = event.getEventId(); // or compose: topic+":"+partition+":"+offset

    // 2. Check + process + mark — all in one DB transaction
    if (processedEventRepo.existsById(dedupKey)) {
        return; // already processed, skip
    }
    orderRepo.save(mapToOrder(event));            // business logic
    processedEventRepo.save(new ProcessedEvent(dedupKey)); // mark done
    // If crash here, DB transaction rolls back → both writes undone → safe retry
}

The key: the DB lookup, business write, and deduplication marker write are all in one atomic transaction. Either all succeed or all roll back. No half-processed state.

Interview drill-down — expect these follow-ups:
L1 → "How do you make a consumer idempotent in practice?"
Give every event a stable unique ID (eventId field, or compose from topic + partition + offset). Before processing, check a deduplication store (DB unique constraint, Redis SET NX, or a processed_events table). If the ID already exists → skip. If not → process + mark as done atomically in the same DB transaction. The key is atomicity: process AND mark must happen together or neither.
L2 → "You use EOS (exactly-once) with Kafka transactions. A consumer receives the same order event twice anyway. How?"
EOS guarantees exactly-once within the Kafka-to-Kafka pipeline (read-process-write). But if the consumer writes to an external system (database, REST API, email), Kafka can't wrap that in its transaction. The consumer may commit the Kafka offset, fail before the DB write completes, restart, and reprocess. EOS doesn't protect external side effects — you still need idempotent consumers for external writes.
L3 → "Your idempotency check uses a DB lookup. At 50k events/sec the DB becomes a bottleneck. How do you scale it?"
Use a Bloom filter as a probabilistic pre-check — O(1) lookup, no DB call for events that were definitely not processed. Only on a Bloom hit (possible duplicate) do you do the DB lookup. Bloom filters have false positives but no false negatives, so you never skip a new event. For TTL-based deduplication windows (last 24h), use a Redis sorted set with a score of epoch time — range-delete expired IDs. Partition the deduplication store by partition key to avoid cross-partition contention.
Trap answer to avoid: "I use enable.idempotence=true on the producer — that makes everything exactly-once." — Idempotent producer only deduplicates producer retries to the broker within a single session (using PID + sequence number). It does NOT make consumers idempotent, does NOT help if the producer process restarts (new PID), and does NOT protect external system writes. It's component 1 of EOS, not a complete solution.
Q3 · Consumer Groups
What is a consumer group rebalance? How do you minimise its impact?

What is a rebalance and why it hurts

A rebalance is the process where Kafka's Group Coordinator (a broker) redistributes partition ownership among consumers in a group. During a rebalance, all consumers in the group stop consuming until the new assignment is complete. This is called a "stop-the-world" rebalance.

  • Triggers: consumer joins or leaves, consumer crashes, heartbeat timeout, partition count change, subscription change.
  • Duration: typically 2–10 seconds, but can be 30s+ in large groups or slow networks.
  • Risk: uncommitted offsets may be reprocessed (duplicate events) after reassignment.

How a rebalance works — the protocol

Step 1: JoinGroup
  All consumers send JoinGroup request to Group Coordinator broker.
  Coordinator picks a "leader consumer" (usually the first one).

Step 2: SyncGroup
  Leader consumer runs the partition assignment algorithm.
  Sends assignments back to the Coordinator (SyncGroup request).
  Coordinator distributes assignments to all other consumers.

Step 3: Consumers resume polling with their new partition assignment.

Timeline:
  Consumer C crashes
    ↓ (session.timeout.ms = 45s elapses)
  Coordinator detects timeout
    ↓
  JoinGroup sent by remaining consumers
    ↓ (2–5 seconds for large groups)
  Assignments redistributed
    ↓
  Consumers resume — total pause ≈ 45s + 5s = ~50s worst case

Eager vs Cooperative rebalance — why it matters

ProtocolBehaviourDowntime
Eager (old default)All consumers revoke ALL partitions before reassignment. Then re-assign from scratch.100% of partitions paused during rebalance
Cooperative Sticky (Kafka 2.4+)Only partitions that actually need to move are revoked. Others keep consuming throughout.Only the moving partitions pause
# Enable cooperative incremental rebalance (recommended):
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor

# With CooperativeStickyAssignor:
# - Consumer A has P0, P1, P2
# - New consumer D joins
# - Only P2 is revoked from A and given to D
# - A keeps consuming P0 and P1 without interruption

Static group membership — eliminating rebalances on rolling restarts

# Give each consumer instance a stable identity:
group.instance.id=order-consumer-pod-3   # e.g. Kubernetes pod name
session.timeout.ms=60000                 # give restarts time to complete

# Without static membership:
# Pod restarts → consumer leaves group (triggers rebalance)
#             → pod comes back → joins group (triggers another rebalance)
# = 2 rebalances per rolling restart, multiplied by pod count

# With static membership:
# Pod restarts → Coordinator holds the assignment for session.timeout.ms
#             → Pod rejoins with same group.instance.id within timeout
#             → Coordinator hands back the same partitions — NO rebalance
For Kubernetes: set group.instance.id to the pod name via the Downward API. Combined with CooperativeStickyAssignor, rolling deployments cause zero rebalances.

Key timing configs and what each controls

session.timeout.ms = 45000      # broker-side: heartbeat timeout window
                                 # consumer considered dead if no heartbeat received
                                 # increased → slower failure detection

heartbeat.interval.ms = 3000    # how often background thread sends heartbeat
                                 # must be < session.timeout.ms / 3
                                 # does NOT affect max.poll.interval.ms

max.poll.interval.ms = 300000   # main thread: max time between poll() calls
                                 # if exceeded, consumer voluntarily leaves group
                                 # this catches "slow processing" cases
                                 # even if heartbeat background thread is alive

max.poll.records = 500          # reduce to keep each poll() cycle fast
                                 # fewer records = more frequent polls = healthier

The poll loop — why blocking it causes rebalances

// WRONG: blocking the poll loop
while (true) {
    var records = consumer.poll(Duration.ofMillis(100));
    for (var record : records) {
        callSlowExternalApi(record);  // blocks for 30s → max.poll.interval exceeded → rebalance
    }
}

// CORRECT: offload slow work, keep poll loop fast
while (true) {
    var records = consumer.poll(Duration.ofMillis(100));
    List<Future> futures = records.stream()
        .map(r -> executor.submit(() -> callSlowExternalApi(r)))
        .collect(toList());
    futures.forEach(f -> f.get()); // wait for all async results
    consumer.commitSync();          // then commit — all done
}
Interview drill-down — expect these follow-ups:
L1 → "What is the difference between session.timeout.ms and max.poll.interval.ms — when does each trigger a rebalance?"
session.timeout.ms: the broker's heartbeat timeout. If the broker doesn't receive a heartbeat within this window, it assumes the consumer is dead and triggers a rebalance. Heartbeats are sent by a background thread — this fires even if the main thread is frozen. max.poll.interval.ms: the time between consumer.poll() calls. If the main thread doesn't call poll() within this window, the consumer voluntarily leaves the group. This catches the case where processing is so slow the main thread never loops back — the broker doesn't know, but the consumer knows it's stuck.
L2 → "Your consumer processes 500 records per poll and occasionally one record calls a synchronous REST API that takes 90 seconds. Rebalances keep firing. How do you fix it?"
Two options: (1) Increase max.poll.interval.ms to above 90s — but this masks real failures. (2) Better: reduce max.poll.records to 1–5 so each poll cycle is fast, or offload the slow REST call to an async executor and only commit offsets after the async result is confirmed. The core principle: never block the poll loop. The consumer thread exists to call poll() — all heavy work must be decoupled from it.
L3 → "A rolling deployment causes rebalances every 30 seconds as pods restart. Consumption lags badly. What do you configure?"
Static group membership: assign each pod a stable group.instance.id (e.g., pod name). When a pod restarts and rejoins within session.timeout.ms, Kafka returns its old partitions without triggering a rebalance. Combined with CooperativeStickyAssignor (incremental rebalance), only the partitions from the offline pod move — the rest of the group keeps consuming. This converts stop-the-world rebalances into partition-migration events.
Trap answer to avoid: "I just increased max.poll.interval.ms to 10 minutes to stop rebalances." — This masks the real problem (slow processing) and means a genuinely dead consumer won't be detected for 10 minutes, blocking an entire partition. The correct fix is to make the poll loop fast, not to inflate the timeout until the symptom disappears.
Q4 · Ordering & Partitioning
How do you choose a partition key? What happens with a bad key?

How partitioning works — the math

The default partitioner computes: partition = murmur2(key) % numPartitions

  • Same key → same hash → same partition → all messages for that key are ordered.
  • Null key → round-robin (Kafka 2.4+: sticky partitioner batches to one partition then rotates) → even distribution, no ordering.
  • The hash is deterministic — given the same key and same partition count, the assignment never changes. This is why increasing partition count breaks existing key routing.
// Producer sends with a key:
producer.send(new ProducerRecord<>("order-events", customerId, orderEvent));
//                                  topic           key         value
// All events for customerId="user-123" → always partition 7 (for example)
// Consumer assigned to partition 7 sees all user-123 events in order

Key selection — what ordering guarantee do you actually need?

Key choiceOrdering guaranteeTrade-off
customerIdAll events per customer orderedGood if customer event volume is even
orderIdAll events per order orderedFine-grained — many distinct keys, even distribution
tenantId (mutable)Per-tenant orderingDanger: if tenantId changes (rename, M&A), same logical tenant maps to different partition — ordering breaks
immutableTenantId (UUID)Per-tenant ordering, stableUUID is stable for the lifetime of data
eventTypePer event typeFew distinct values → hot partitions
nullNoneEven distribution, maximum parallelism
Real-world example: In multi-tenant SaaS platforms, use the tenant's immutable UUID (not display name) as partition key. If a company is acquired and renamed, the mutable tenantId changes — you get a new partition, and event ordering across the rename is broken.

Hot partition problem — cause and fix

// Problem: VIP tenant generates 80% of traffic
// All their events → partition 3 → Consumer C overwhelmed
// Other partitions idle

Topic "catalog-events" (8 partitions):
  P0:  10 msg/s  ← normal tenant
  P1:  12 msg/s  ← normal tenant
  P2:   8 msg/s  ← normal tenant
  P3: 840 msg/s  ← VIP tenant (hot!)
  ...

// Fix option 1: compound key with salt (breaks strict per-tenant ordering)
String key = tenantId + "-" + (eventSeq % 4);
// Spreads VIP tenant across 4 partitions.
// Trade-off: ordering is now per (tenantId, salt), not per tenantId.
// To reconstruct full order for that tenant → sort by event timestamp in consumer.

// Fix option 2: dedicated topic for high-volume tenants
// Route VIP tenants to "catalog-events-highvol" (more partitions)
// Normal tenants to "catalog-events" (fewer partitions)

// Fix option 3: pre-aggregate before publishing
// Batch multiple small events into one larger event per tenant → fewer messages

Ordering and producer retries — the subtle bug

// Problem: producer sends batch [A, B, C] to partition 3
// A succeeds, B fails (network blip), C succeeds
// Producer retries B → broker receives [A, C, B] → wrong order!

// This happens when max.in.flight.requests.per.connection > 1
// (multiple in-flight batches per partition)

// Fix: enable idempotent producer — safe with up to 5 in-flight:
enable.idempotence=true
// Idempotent producer uses sequence numbers per (PID, partition).
// Broker buffers out-of-order sequences and reorders them.
// Safe retries + correct ordering + no duplicates.

// Alternative (simpler but slower):
max.in.flight.requests.per.connection=1  // forces ordering but kills batching throughput

Shared topics — multi-tenant isolation without separate clusters

In a SaaS platform, creating a separate topic per tenant is impractical at scale. The pattern is to share topics but enforce isolation through layered controls:

// Layer 1: partition key = immutableTenantId (routing + ordering)
// Layer 2: envelope metadata (tenantId in message header or payload)
// Layer 3: consumer-side filter (skip messages not for this tenant)
// Layer 4: data-layer isolation (each tenant's data in separate DB schema)
// Layer 5: audit log per tenant (all events tagged)

// Consumer filter example:
records.stream()
    .filter(r -> myTenantId.equals(r.headers().lastHeader("tenantId").value()))
    .forEach(this::processEvent);

This approach scales to thousands of tenants without partition explosion, while maintaining per-tenant ordering via the partition key.

Interview drill-down — expect these follow-ups:
L1 → "A product team insists on global ordering across all partitions for audit events. What do you tell them?"
Global ordering requires a single partition — that means a single consumer, and throughput is capped at whatever one consumer can process. For audit events, this is usually acceptable because audit volume is low. The real question is: do you actually need global ordering, or do you need ordering per entity (per user, per order)? Entity-level ordering is achievable with partitioning by entity ID and scales horizontally. True global ordering is a design smell in high-throughput systems.
L2 → "You have 10 million users and partition by userId. A viral event causes 0.1% of users to generate 60% of traffic. How do you fix the hot partition?"
Compound key with a salt: userId + (System.currentTimeMillis() % N) where N is the fan-out factor (e.g., 3). This splits the hot user's traffic across N partitions. The cost: ordering is now per (userId, salt) sub-key, not per userId. If you need to reconstruct global order for that user, you need a secondary read-side that reorders by event timestamp. For write-heavy scenarios, this trade-off is usually worth it.
L3 → "You're running out of partitions (currently 100) and need more parallelism. Can you increase partition count safely?"
You can increase partition count, but messages with the same key will get rerouted — hash(key) % 200 != hash(key) % 100. If you rely on per-key ordering (all events for customerId=X land on the same partition), increasing partitions breaks that guarantee for new messages while old messages stay on old partitions. Safe approach: use a custom partitioner that doesn't remap existing keys, or do a blue-green migration — create a new topic with more partitions and route new traffic there while draining the old one.
Trap answer to avoid: "For global ordering I'll use a single partition and add more consumers for parallelism." — You cannot parallelize consumption of a single partition. Only one consumer in a group reads a given partition at any time. More consumers beyond the partition count are idle. This answer reveals a misunderstanding of the partition-consumer model.
Q5 · Offsets
Explain consumer offset management. Auto vs manual commit — which and when?

What an offset is — and what it isn't

An offset is a 64-bit integer that identifies a message's position within a single partition. It's monotonically increasing and never reused, even after a message is deleted by retention policy.

Offsets are per (consumer group, topic, partition) — stored in Kafka's internal topic __consumer_offsets. Two consumer groups reading the same topic have completely independent offsets.

Topic: order-events, Partition 3
  offset=0: OrderCreated (Jan 1)   ← may be deleted by retention
  offset=1: OrderUpdated (Jan 2)   ← may be deleted by retention
  offset=2: OrderShipped (Jan 3)
  ...
  offset=8429: OrderCancelled (today)  ← log-end-offset

Consumer Group "fulfillment": committed offset = 8427
  → lag = 8429 - 8427 = 2 messages behind

Auto-commit — why it creates data loss and duplicates

enable.auto.commit=true
auto.commit.interval.ms=5000  # commits every 5 seconds, regardless of processing state

// Timeline showing the problem:
//
//  t=0:   poll() returns records [100, 101, 102, 103, 104]
//  t=2:   auto-commit fires → commits offset=104 (all "read")
//  t=3:   consumer crashes mid-processing (processed 100, 101 only)
//  t=4:   consumer restarts → reads from committed offset=105
//         → records 102, 103, 104 are SKIPPED FOREVER (data loss!)
//
//  OR:
//  t=0:   poll() returns records [100..104]
//  t=4.9: consumer finishes processing all 5 records
//  t=5:   auto-commit fires → commits 104
//  t=5.1: commit fails (network error)
//  t=5.2: consumer restarts → re-reads from 100 → DUPLICATES

Manual commit — the right pattern

while (true) {
    ConsumerRecords<String, Event> records = consumer.poll(Duration.ofMillis(100));

    for (ConsumerRecord<String, Event> record : records) {
        processEvent(record.value());   // 1. process first
    }
    consumer.commitSync();              // 2. then commit — blocks until broker confirms
    // commitSync() throws on failure → retry loop handles it
    // if crash before commitSync() → restart re-reads same records (at-least-once, safe)
}

// commitAsync() vs commitSync():
// commitAsync() — non-blocking, ~2x throughput. But:
//   - may fail silently (callback-only)
//   - retrying async commits can cause out-of-order commits (later offset committed before earlier)
// Best practice: commitAsync() in the hot loop, commitSync() on shutdown/rebalance

// Hybrid pattern (high throughput + safe shutdown):
try {
    while (true) {
        var records = consumer.poll(Duration.ofMillis(100));
        process(records);
        consumer.commitAsync(); // fast path
    }
} finally {
    consumer.commitSync(); // guarantee last commit on shutdown
    consumer.close();
}

Committing specific offsets — for batch processing

// For fine-grained control (e.g., processed records from multiple partitions):
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();

for (ConsumerRecord<String, Event> record : records) {
    processEvent(record);
    // Commit after EACH record — lowest lag risk, but slow
    offsets.put(
        new TopicPartition(record.topic(), record.partition()),
        new OffsetAndMetadata(record.offset() + 1)  // +1 = "start reading from here next"
    );
}
consumer.commitSync(offsets);

// Contiguous offset commit — only commit up to the last contiguous processed offset.
// Used in batch subscribers where some records may still be in-flight:
//
//  Processed: [100, 101, _, 103, 104]  (102 still processing)
//  Safe to commit: offset=102 (not 104 — can't skip 102)

Consumer lag — the operational metric that matters

// Consumer lag = log-end-offset − committed-offset (per partition)
// Represents: how many messages are waiting to be processed

// Monitor via:
// 1. JMX: kafka.consumer:records-lag-max
// 2. AdminClient API: compare group offsets vs log-end-offsets
// 3. Lag exporter tools: Burrow, kafka-consumer-lag-exporter → Prometheus Gauge

// Alert rules:
// lag > 10_000 AND processing_rate > 0 → consumer is slow, investigate
// lag > 10_000 AND processing_rate == 0 → consumer is dead, page on-call
// lag growing faster than message rate → consumer falling behind

// Production pitfall: lag is per partition, not per topic.
// A topic with 10 partitions may have lag=0 on 9 and lag=500_000 on 1 (hot partition).
// Always monitor per-partition lag, not just aggregate.

auto.offset.reset — what happens when there's no committed offset

auto.offset.reset=latest  # (default) start reading from now — skip past messages
                           # used for: new consumer groups that don't need history
auto.offset.reset=earliest # start from beginning of the log (retention window)
                            # used for: catch-up consumers, replay scenarios
auto.offset.reset=none     # throw exception if no offset found
                            # used for: critical consumers where "I don't know where I am" = bug

// Triggers: new consumer group, or committed offset has been deleted (past retention)
// Does NOT affect consumers with a valid committed offset.
Interview drill-down — expect these follow-ups:
L1 → "What is consumer lag and how do you monitor it in production?"
Consumer lag = latest offset in partition (log-end-offset) minus last committed offset for the consumer group. It represents the number of unprocessed messages. Monitor via JMX metric kafka.consumer:records-lag-max, or use Kafka's AdminClient to compare group offsets against log-end offsets. Expose as a Prometheus Gauge (not Counter) via a lag exporter like Burrow or kafka-consumer-lag-exporter. Alert when lag exceeds your SLA processing window — e.g., lag > 10,000 on a topic where messages must be processed within 60s.
L2 → "Consumer lag is growing. How do you diagnose whether to add consumers or fix the consumer itself?"
First check partition utilisation: if some consumers are idle (have 0 partitions assigned) while others are overwhelmed, you likely have a hot partition problem or partition count is less than consumer count. If all consumers are active and lag is growing uniformly, throughput is the problem — check consumer processing time (RED metrics: request duration, error rate). If processing is fast but lag grows, check network/broker throughput. Adding consumers only helps if you also add partitions — you can't have more consumers than partitions in a group.
L3 → "You have a consumer group with 20 consumers, 20 partitions, and lag is 5 million. Adding more partitions is not possible. What do you do?"
Horizontal scaling of consumers is maxed out. Options: (1) Scale vertically — increase consumer processing throughput by parallelising within the consumer (process records concurrently with a thread pool, but only commit offsets after all records in the batch complete). (2) Optimize the processing logic — batch database writes instead of per-record inserts. (3) If it's a backlog spike, enable a temporary catch-up consumer group reading from the same topic but using an earlier offset reset, with its own dedicated downstream path.
Trap answer to avoid: "I monitor consumer lag with Kafka's auto.offset.reset setting." — auto.offset.reset is a consumer configuration for what to do when there's no committed offset (latest vs earliest). It has nothing to do with monitoring lag. This confusion reveals a misunderstanding of offset management vs lag monitoring.
Q6 · Patterns
What is the Outbox Pattern? Why is it critical for reliable event publishing?

The dual-write problem — why it's unsolvable without a pattern

A service must atomically: (1) update the database and (2) publish a Kafka event. These are two different transactional systems. There is no XA/2PC that covers both. Any dual write has a failure window:

// Scenario A: crash after DB write, before Kafka send
db.save(order);           // ✓ committed
// ← crash here
kafka.send(orderEvent);   // never happens → downstream never knows

// Scenario B: Kafka send succeeds, DB rollback
kafka.send(orderEvent);   // ✓ sent
db.save(order);           // throws → rollback
// Downstream acted on an event for an order that doesn't exist in DB

// Both scenarios cause inconsistency. The Outbox pattern solves this.

The Outbox solution — one transaction, two writes

// outbox table schema:
CREATE TABLE outbox_events (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    topic       VARCHAR NOT NULL,         -- target Kafka topic
    partition_key VARCHAR,                -- used as Kafka message key
    payload     JSONB NOT NULL,           -- the event data
    created_at  TIMESTAMP DEFAULT NOW(),
    sent_at     TIMESTAMP                 -- null until relayed
);

// Service writes both in the same DB transaction:
@Transactional
public void placeOrder(Order order) {
    orderRepo.save(order);                   // business write
    outboxRepo.save(OutboxEvent.of(          // event write — same transaction
        "order-events",
        order.getId().toString(),            // partition key
        OrderEventMapper.toJson(order)       // payload
    ));
    // If anything fails → both writes roll back → consistent state
    // DB commit succeeds → both writes persist → relay will find and publish the event
}

Two relay approaches — CDC vs Polling

ApproachHow it worksProsCons
Debezium CDCReads DB transaction log (WAL/binlog). Captures every INSERT to outbox table in near-real-time.Sub-second latency. No polling load on DB. No missed events.Complex setup (Kafka Connect cluster). WAL retention must be configured. Schema changes can break connector.
Polling relayScheduled job: SELECT * FROM outbox WHERE sent_at IS NULL ORDER BY created_at LIMIT 100. Publishes, then marks sent.Simple. No extra infrastructure. Works with any DB.Polling interval = latency. DB load at scale. Thundering herd if backlog grows. Concurrent relay instances need locking.
// Polling relay — safe concurrent version with optimistic locking:
UPDATE outbox_events
   SET sent_at = NOW(), relay_id = :myInstanceId
 WHERE id IN (
     SELECT id FROM outbox_events
      WHERE sent_at IS NULL
        AND relay_id IS NULL      -- not claimed yet
      ORDER BY created_at
      LIMIT 100
      FOR UPDATE SKIP LOCKED      -- skip rows locked by other relay instances
 );
// Returns only the rows this instance claimed → no duplicate publishing

Debezium CDC — how it tracks position

// Debezium reads the DB transaction log (WAL for PostgreSQL, binlog for MySQL).
// It commits its WAL position to a Kafka Connect offset topic.
//
// On crash + restart:
//   1. Reads last committed WAL offset from Kafka Connect offset topic
//   2. Resumes reading WAL from that position
//   3. Re-emits any events that were published but not yet offset-committed
//   → at-least-once delivery — your consumers must be idempotent
//
// Key config for PostgreSQL:
// wal_level=logical  (in postgresql.conf)
// max_replication_slots=10  (each Debezium connector = 1 slot)
// max_wal_senders=10
//
// Debezium connector config (kafka-connect):
database.hostname=mydb.internal
database.port=5432
database.dbname=orders
table.include.list=public.outbox_events
transforms=outbox
transforms.outbox.type=io.debezium.transforms.outbox.EventRouter

Why Outbox is at-least-once — not exactly-once

Key insight: The relay may publish to Kafka and then crash before marking the row as sent_at. On restart, it sees the row still unmarked → publishes again → duplicate event in Kafka. The Outbox pattern guarantees no event is lost, but does not guarantee no duplicates. Your consumers must be idempotent. Include a stable eventId (the outbox row UUID) in the event payload for consumers to use as a deduplication key.
Interview drill-down — expect these follow-ups:
L1 → "You use Debezium for the Outbox relay. The Debezium connector crashes and restarts. What happens to events?"
Debezium tracks its position in the database's transaction log (WAL offset stored in Kafka Connect's offset topic). On restart, it resumes from where it left off — events that were published before the crash but after the last committed offset will be re-published. Your consumers must be idempotent. This is correct at-least-once behaviour — events are never lost, but may be duplicated. The outbox row should include a stable eventId that consumers use for deduplication.
L2 → "The polling relay approach has a flaw: if it publishes the event to Kafka but crashes before marking the outbox row as 'sent', the event is republished on restart. How do you prevent duplicate consumer processing at scale?"
You don't prevent duplication at the producer — you accept at-least-once and handle it at the consumer. Consumer deduplication: store processed eventIds in a Redis SET with a TTL matching your business window (e.g., 48h). Before processing, check SET → if already exists, skip. If not, process + SADD atomically using a Lua script or Redis pipeline. For DB consumers, a unique constraint on event_id in the processed events table achieves the same with automatic rollback semantics.
L3 → "Your outbox table has 10 million unprocessed rows because Debezium was down for 2 days. Replay is causing load on the primary DB. What do you do?"
Don't replay from the primary. Options: (1) Point Debezium at a read replica's WAL (PostgreSQL logical replication supports this). (2) If the backlog is in the outbox table itself, use the polling relay with pagination and a configurable batch size + sleep interval to throttle. (3) Partition the catch-up: process old events via a separate consumer group with a separate Kafka topic/partition so live traffic is unaffected. Crucially — audit downstream consumers for idempotency before triggering any replay.
Trap answer to avoid: "The Outbox pattern guarantees exactly-once delivery." — Outbox guarantees at-least-once. The relay (Debezium or polling) may re-publish if it fails between publish and marking-as-sent. Exactly-once requires consumer-side deduplication in addition to the outbox. Claiming the outbox itself is exactly-once shows a misunderstanding of where the duplicate risk lives.
Q7 · Error Handling
What is a poison pill message? How do you handle it with Dead Letter Topics?

The poison pill problem — why it's catastrophic

A poison pill is a message that always fails processing (malformed JSON, unexpected schema version, null field your code doesn't handle). The consumer retries → fails → retries → fails. Meanwhile:

// Partition consumption is blocked:
//
// Partition 3 message stream:
//   offset=100: OrderCreated   ← processed OK
//   offset=101: ??? BAD JSON   ← fails every time, stuck here
//   offset=102: OrderUpdated   ← CAN'T REACH — 101 must be committed first
//   offset=103: OrderShipped   ← CAN'T REACH
//   ...
//
// Consumer keeps retrying 101. 102, 103, ... pile up.
// Partition lag grows. SLA violated. Downstream services wait.
// The entire partition is effectively dead until 101 is handled.

Dead Letter Topic — the escape hatch

// Spring Kafka DefaultErrorHandler with DLT — full config:
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<?, ?> template) {
    // Where to send failed messages:
    DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template,
        (record, ex) -> new TopicPartition(
            record.topic() + ".DLT",  // e.g. "order-events.DLT"
            record.partition()         // same partition — preserves ordering signal
        ));

    // Retry policy — backoff before giving up:
    ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
    backOff.setInitialInterval(1000L);   // 1s, 2s, 4s
    backOff.setMultiplier(2.0);
    backOff.setMaxInterval(10_000L);  // cap at 10s

    DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);

    // Don't waste retries on permanent failures — send to DLT immediately:
    handler.addNotRetryableExceptions(
        DeserializationException.class,         // bad JSON/Avro — won't fix itself
        DataIntegrityViolationException.class,  // DB constraint — won't fix itself
        IllegalArgumentException.class          // validation error — won't fix itself
    );
    return handler;
}

Retry topic chain — for transient failures with longer backoff

// Spring Kafka @RetryableTopic — creates retry topics automatically:
@RetryableTopic(
    attempts = "4",
    backoff = @Backoff(delay = 5000, multiplier = 6, maxDelay = 300_000),
    dltStrategy = DltStrategy.FAIL_ON_ERROR,
    topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_DELAY_VALUE
)
@KafkaListener(topics = "order-events")
public void processOrder(OrderEvent event) { ... }

// Auto-created topics:
//   order-events              ← original
//   order-events-retry-5000   ← retry after 5s
//   order-events-retry-30000  ← retry after 30s
//   order-events-retry-180000 ← retry after 3min
//   order-events-dlt          ← final dead letter

// Why this is better than simple retries:
//   Main topic never slows down — poison pills are moved to retry topics.
//   Retry topics have their own consumer groups with scheduled delays.
//   If downstream service is down for 5 minutes, events wait in retry-3min — no partition blockage.

DLT message headers — forensics data

// Spring Kafka automatically adds headers to DLT messages:
// kafka_dlt-original-topic      = "order-events"
// kafka_dlt-original-partition  = "3"
// kafka_dlt-original-offset     = "101"
// kafka_dlt-exception-fqcn      = "com.fasterxml.jackson.core.JsonParseException"
// kafka_dlt-exception-message   = "Unexpected character..."
// kafka_dlt-original-timestamp  = "1693000000000"

// Use these in your DLT consumer to:
// 1. Alert with context (which topic, which offset, what error)
// 2. Correlate with business data (use original-offset to find the raw message)
// 3. Decide replay strategy (can we fix and re-publish?)

Exception classification — the critical design decision

Exception typeRetryable?ExamplesAction
TransientYesConnectException, TimeoutException, DB pool exhaustionRetry with exponential backoff
PermanentNoDeserializationException, DataIntegrityViolationException, NullPointerException on bad dataSkip retries, send directly to DLT
UnknownYes (conservative)Any uncaught exceptionRetry N times, then DLT
Never silently drop a failed message. Silently skipping = data loss with no signal. DLT = data loss with an alert. Always prefer DLT. In financial/data systems, every event has business significance and must be traceable.
Interview drill-down — expect these follow-ups:
L1 → "How do you replay DLT messages after fixing the bug that caused them to fail?"
Option 1: kafka-consumer-groups CLI — reset the DLT consumer group offset to earliest, restart the consumer, let it reprocess. Option 2: a dedicated replay service that reads from the DLT, strips any retry headers, and re-publishes to the original topic. Option 3: in Spring Kafka, annotate the DLT handler with @RetryableTopic(attempts = 1) and manually trigger reprocessing. Critical pre-condition: verify the bug fix is deployed, and ensure consumers are idempotent — replay may re-process events that originally succeeded before failing.
L2 → "How do you distinguish a poison pill (will always fail) from a transient failure (retry will work) in your error handler?"
Classify by exception type. Transient failures: network timeouts, DB connection errors, downstream service unavailable (ConnectException, TimeoutException) → retry with backoff. Permanent failures: deserialization errors, constraint violations, NullPointerException from malformed data (DeserializationException, DataIntegrityViolationException) → skip retries, send directly to DLT. In Spring Kafka DefaultErrorHandler, use addNotRetryableExceptions() to configure the non-retryable list. Always log the original message + stack trace to the DLT message headers for forensics.
L3 → "Your DLT has 50,000 messages. You fix the bug and replay. Halfway through, 5,000 fail again with a different bug. How do you handle this?"
The replay itself must be idempotent and resumable. Use a replay service that tracks its own progress (e.g., checkpointing every N records to Redis or a DB table). On the second failure batch, route to a secondary DLT (DLT.2) rather than re-adding to the same DLT — avoids infinite loops. Emit a metric per replay attempt so you can detect if specific message IDs keep failing (poison pill within the DLT). Add a max-replay-attempts field in the message header; if exceeded, quarantine to dead storage with an alert.
Trap answer to avoid: "I just increase the retry count to 10 to handle transient failures." — High retry counts for poison pills block partition consumption for potentially minutes per message. A message that will always fail (malformed schema, logic bug) wastes retries and keeps falling behind. The correct approach is fast-fail on known-permanent exceptions and limit retries for transient ones, sending to DLT promptly.
Q8 · Stream Processing
Kafka Streams vs Consumer API — when do you use each?

Consumer API — what it is and when it's right

The Consumer API is Kafka's low-level poll-based interface. You write the loop, manage offsets, handle errors, and build any stateful logic yourself.

// Consumer API is the right choice when:
// - Simple event forwarding: read from topic A, write to DB, call REST API
// - You need full control over offset commits (batch acknowledgement, per-record ack)
// - You're integrating with external systems (the "write to DB" part can't be inside a Kafka transaction)
// - Processing is stateless or state lives in an external DB

while (true) {
    ConsumerRecords<String, Event> records = consumer.poll(Duration.ofMillis(100));
    for (var record : records) {
        myService.process(record.value());   // call DB, REST, etc.
    }
    consumer.commitSync();
}

Kafka Streams — what it adds and how it works

Kafka Streams is a client library (not a separate cluster) that builds on the Consumer/Producer APIs. It adds: a DSL for stream operations, automatic offset management, stateful operations backed by RocksDB, windowing, exactly-once for Kafka-to-Kafka, and fault-tolerant state recovery via changelog topics.

// Kafka Streams is the right choice when:
// - Aggregations: count events per key in 5-minute windows
// - Joins: enrich order events with customer data from another topic
// - Real-time analytics: running totals, sliding window fraud detection
// - The output is another Kafka topic (Kafka→Kafka pipelines)

StreamsBuilder builder = new StreamsBuilder();
KStream<String, Order> orders = builder.stream("orders");

KTable<Windowed<String>, Long> orderCounts = orders
    .filter((key, order) -> order.getAmount().compareTo(THRESHOLD) > 0)
    .groupByKey()
    .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
    .count(Materialized.as("order-count-store")); // backed by RocksDB

orderCounts
    .toStream()
    .filter((key, count) -> count > 10)
    .to("fraud-alerts");                         // output to Kafka topic

State stores — how Kafka Streams manages stateful operations

// Every stateful operation (count, aggregate, join) uses a state store.
// Default implementation: RocksDB (embedded key-value store, persisted to disk).
//
// State store architecture:
//
//   Kafka Streams app instance
//   ├── Task 0 (owns partition 0)
//   │   └── RocksDB state store (local disk)
//   │       └── changelog topic: my-app-order-count-store-changelog-0
//   ├── Task 1 (owns partition 1)
//   │   └── RocksDB state store (local disk)
//   │       └── changelog topic: my-app-order-count-store-changelog-1
//   ...
//
// Every state change is written to a compacted changelog Kafka topic.
// On crash + restart: Streams replays the changelog to rebuild state.
// This can be slow for large state (millions of keys).
//
// Speed it up with standby replicas:
num.standby.replicas=1
// A second instance mirrors the state store continuously.
// On failure, it's promoted → near-instant recovery (state already warm).

Decision guide

Use caseChooseWhy
Read events, write to DB / REST APIConsumer APIExternal writes are outside Kafka transactions; simpler
Count/sum events per key over time windowsKafka StreamsBuilt-in windowing + RocksDB state, fault-tolerant
Join two event streams (orders + payments)Kafka StreamsKStream-KTable join with automatic co-partitioning
Simple topic-to-topic forwarding / fan-outConsumer APIOverhead of Streams DSL not justified
Real-time fraud scoring (sliding windows)Kafka StreamsSliding window aggregation + low-latency read from state store
Complex event processing needing SQLksqlDBSQL over Kafka Streams; consider if team prefers declarative

Kafka Streams internals — tasks, threads, and topology

// Topology: the DAG of processing nodes (source → processor → sink)
// Task: one unit of work = owns one input partition + its state stores
// Thread: a StreamThread runs multiple tasks
//
// Parallelism is controlled by:
// 1. Number of partitions in source topic (= max tasks)
// 2. num.stream.threads (tasks per instance)
// 3. Number of app instances (scale out)
//
// Example: 12 partitions, 3 instances, 2 threads each
//   = 12 tasks, 6 threads total
//   = each thread handles 2 tasks

num.stream.threads=4        // 4 StreamThreads per JVM instance
application.id=order-stats  // unique ID = consumer group ID + changelog topic prefix
                             // NEVER change this in production without migrating state
Interview drill-down — expect these follow-ups:
L1 → "How does Kafka Streams recover its RocksDB state store after a crash?"
Every state store change is written to a compacted changelog topic (e.g., my-app-my-store-changelog). On restart, the Streams app replays this changelog from the beginning to rebuild the in-memory/RocksDB state. For large state stores, this can take minutes. To speed up recovery, enable standby replicas (num.standby.replicas=1): a standby task continuously mirrors the state store on another instance. If the primary fails, the standby is promoted — recovery is near-instant since the state is already warm.
L2 → "You have a tumbling window of 5 minutes aggregating order counts. A record arrives 10 minutes late. What happens?"
By default, Kafka Streams closes a window after the window end time plus the grace period. With TimeWindows.ofSizeWithNoGrace(5 minutes), there's zero tolerance — the late record is dropped. With TimeWindows.of(5 minutes).grace(2 minutes), records up to 2 minutes late are accepted. Beyond the grace period, the window is sealed and late records are sent to a late-arrival handler if configured. For event-time semantics to work, records must carry an event timestamp (not ingestion time) — configure TimestampExtractor to read from the message payload.
L3 → "Your Kafka Streams application is stateful and you need to do a zero-downtime schema migration on the state store. How?"
Kafka Streams state stores are not directly schema-upgradeable in place. The safe approach: (1) Deploy a new version of the app with a new application.id — it starts with empty state and rebuilds from scratch via the source topic (not the changelog). Run both old and new in parallel. (2) Once the new instance has caught up and state is warm, switch traffic and shut down the old. (3) If rebuilding from source is too slow, write a migration job that reads the old changelog and produces to a new changelog with the new schema. State store migrations are one of the hardest operational challenges in Kafka Streams.
Trap answer to avoid: "Kafka Streams runs inside Kafka — it's a separate processing cluster." — Kafka Streams is a client library that runs inside your application JVM. There is no separate Streams cluster. It uses the Consumer and Producer APIs under the hood. Confusing it with a separate processing tier (like Flink or Spark) reveals a fundamental misunderstanding of what Kafka Streams is.