system-design51 min read

Apache Kafka Deep Dive: The Complete System Design Guide � A Senior+ Guide | Ayodhyya

Apache Kafka Deep Dive: The Complete System Design Guide � A Senior+ Guide | Ayodhyya

Apache Kafka Deep Dive: The Complete System Design Guide

A Senior+ Guide to building distributed, fault-tolerant, high-throughput event streaming platforms like Apache Kafka

Senior+ Guide 90+ min read 10,000+ words Ayodhyya

Table of Contents

  1. Introduction � The Event Streaming Revolution
  2. Functional & Non-Functional Requirements
  3. Capacity Estimation & Back-of-Envelope Calculations
  4. Data Model & Message Structure
  5. API Design � Producer & Consumer Protocols
  6. High-Level Architecture
  7. Storage Engine � Log Segments & Indexing
  8. Partitioning Strategy & Leader Election
  9. Replication, ISR & Durability Guarantees
  10. Consumer Groups, Offsets & Rebalancing
  11. Delivery Semantics � At-Most-Once, At-Least-Once, Exactly-Once
  12. Performance Optimization & Zero-Copy
  13. Producer & Consumer Batching Mechanics
  14. Log Compaction & Cleanup Policies
  15. KRaft Mode � ZooKeeper Elimination
  16. Transactions, Idempotence & E2E Exactly-Once
  17. Kafka Connect & Stream Processing
  18. Security � TLS, SASL, ACLs & Encryption
  19. Monitoring, Metrics & Observability
  20. Tiered Storage � Moving Warm Data to Object Stores
  21. Multi-Region & Cross-Datacenter Replication
  22. Scaling Strategies & Cluster Expansion
  23. Production Incidents & Runbooks
  24. Cost Estimation & Hardware Sizing
  25. Edge Cases & Failure Modes
  26. Interview Q&A � 50+ Questions
  27. Conclusion & Key Takeaways

1. Introduction � The Event Streaming Revolution

Apache Kafka has fundamentally transformed how modern distributed systems handle data. Born at LinkedIn in 2011 and later open-sourced through the Apache Software Foundation, Kafka grew from a simple pub-sub messaging system into the de facto standard for event streaming at scale. Companies like Uber, Netflix, Airbnb, Twitter, and thousands of others run Kafka clusters that process trillions of events per day. The core innovation behind Kafka is its log-based storage model: rather than treating messages as transient entities that are deleted after consumption, Kafka persists them to a durable, append-only log. This design decision unlocks capabilities that traditional message queues simply cannot provide: replayability, multi-subscriber fan-out, long-term retention, and the foundation for stream processing.

Kafka today handles workloads ranging from a few hundred messages per second to tens of millions. Clusters with 1000+ brokers and 100,000+ partitions are not uncommon in large-scale deployments. The system has evolved to include Kafka Connect for integrating with external systems, Kafka Streams for stateful stream processing, KSQL for SQL-based stream queries, and KRaft mode that eliminates the ZooKeeper dependency. Understanding Kafka architecture at a deep level is essential for any senior engineer working on distributed systems, data pipelines, or microservices.

The event streaming paradigm shifts the architecture from request-response to event-driven. Instead of services calling each other synchronously, they emit events to Kafka topics, and downstream services consume those events asynchronously. This decoupling provides resilience, scalability, and the ability to audit every state change in the system. Events are facts that record what happened, not what should happen. This distinction is subtle but profound. When you emit an event like OrderPlaced, you are recording an immutable fact. Downstream systems can interpret that fact however they choose: send a confirmation email, update inventory, charge a credit card, or update a search index. Each consumer reads the same event stream but processes it independently.

Kafka impact on the industry cannot be overstated. It has enabled the rise of event-driven architectures, CQRS patterns, event sourcing, and change data capture (CDC). It powers real-time analytics pipelines, log aggregation, metrics monitoring, and fraud detection systems. The ecosystem around Kafka � Schema Registry, REST Proxy, Kafka Connect connectors, stream processing frameworks like Flink and Spark Streaming � has created a rich platform for building data-intensive applications. In this comprehensive guide, we will explore every aspect of Kafka system design, from the low-level storage engine to high-level architectural patterns, with production-tested C# examples, performance benchmarks, and interview preparation for senior engineering roles at FAANG and Tier-1 companies.

Interview Context: Kafka is one of the most frequently asked system design topics at FAANG and Tier-1 companies. Interviewers want to see your understanding of distributed consensus, log-structured storage, partitioning, replication, and exactly-once semantics. This guide prepares you for both the design conversation and the deep-dive follow-ups.

2. Functional & Non-Functional Requirements

Functional Requirements

#RequirementPriorityDetails
F1Publish eventsMustProducers send records to topics with optional key-based partitioning
F2Consume eventsMustConsumers poll records from partitions with offset tracking
F3Topic managementMustCreate, delete, alter topics with configurable partitions and replication
F4Offset managementMustConsumers commit offsets for resumption after failure
F5Delivery semanticsShouldAt-most-once, at-least-once, exactly-once configurations
F6Schema managementShouldSchema Registry integration for Avro, Protobuf, JSON Schema
F7Log compactionShouldRetain the latest value per key for changelog semantics
F8Connect APINiceSource and sink connectors for database, S3, Elasticsearch integration
F9Stream processingNiceStateful stream processing with exactly-once guarantees

Non-Functional Requirements

RequirementTargetRationale
Throughput1M+ messages/sec per clusterHandles peak traffic for large-scale event pipelines
Produce latencyp99 less than 10msProducers get fast acknowledgement for time-sensitive writes
Consume latencyp99 less than 20msConsumers see data shortly after production
DurabilityNo data loss once ackedFSync on ack all with min.insync.replicas ensures persistence
Availability99.99 percentBroker failure transparently handled via leader election
Partition countUp to 100K per clusterKRaft mode supports large partition counts
Message sizeUp to 10MB default, 100MB maxSupports both small events and large payloads
RetentionConfigurable by time (7d default) or sizeFlexible data retention policies per use case
Design Trade-off: Higher durability (acks=all, min.insync.replicas=3) increases write latency. For latency-sensitive workloads, consider acks=1 and rely on replication to recover from leader failure.

3. Capacity Estimation & Back-of-Envelope Calculations

Before designing a Kafka cluster, we must estimate the required capacity. Let us work through a realistic example: a mid-to-large e-commerce platform processing clickstream data, order events, and system logs.

Traffic Estimates

Assume 500,000 messages per second average, with peaks at 2M messages per second during flash sales. Average message size is 2KB (including headers, key, value, and metadata). This gives us 1GB/s ingress at average traffic and 4GB/s at peak. Over 24 hours, average traffic produces 86.4TB of raw data. With a 7-day retention policy, we need to store approximately 605TB of data. With replication factor 3, the total raw storage requirement is approximately 1.8PB.

MetricValueCalculation
Average throughput500K msg/sAssumption
Peak throughput2M msg/s4x average
Message size2 KBIncluding key, value, headers, metadata
Ingress rate (avg)1 GB/s500K x 2KB
Ingress rate (peak)4 GB/s2M x 2KB
Daily raw data86.4 TB1 GB/s x 86400s
7-day retention604.8 TB86.4 TB x 7
Replication factor 31.81 PB604.8 TB x 3

Broker Sizing

We need to choose the number of brokers. Each broker should handle a reasonable portion of the total throughput. Modern NVMe drives can sustain 3-6 GB/s sequential reads and 2-4 GB/s sequential writes. Network bandwidth is typically 25 Gbps (about 3 GB/s) or 100 Gbps (about 12.5 GB/s) per broker. Using 20 brokers with 50 TB NVMe storage each is a reasonable starting point, but replication and growth must be factored in.

Broker SpecValueRationale
Broker count20Balances load, provides fault tolerance
CPU32 cores2x Intel Xeon Gold 16C
Memory128 GB64 GB for JVM heap, 64 GB for page cache
Storage per broker50 TB NVMe1 PB raw / 20 brokers = 50 TB each
Network2 x 25 Gbps50 Gbps total, enough for 4 GB/s ingress
Key Insight: The page cache is critical for Kafka performance. Allocating only 50 percent of memory to the JVM heap leaves the rest for the OS page cache, which caches recently written and read data. Kafka zero-copy sendfile() reads directly from the page cache to the network card, completely bypassing the JVM heap for consumer fetches.

The network is often the bottleneck in high-throughput Kafka clusters. At 4 GB/s peak ingress, we need 40 Gbps aggregate write bandwidth just for production. With replication factor 3, each message written to the leader generates two additional network transfers to followers, bringing total write bandwidth to 120 Gbps. Consumer traffic adds more. This is why 25 Gbps or 100 Gbps NICs are standard in production Kafka clusters.

4. Data Model & Message Structure

Kafka data model is simple yet powerful. A message (also called a record or event) is a key-value pair with optional headers and metadata. Messages are organized into topics, which are further divided into partitions. Each partition is an ordered, immutable sequence of messages. Every message within a partition has a unique, monotonically increasing offset, a 64-bit integer that identifies its position in the log. This offset is the primary mechanism for consumers to track their progress.

Message Format

The wire format for a Kafka record has evolved through versions v0, v1, v2 (current). The v2 format introduced variable-length integers (varint) to reduce overhead and added a flexible headers section. Each record on disk includes: length (total record size in bytes, varint encoding), attributes (bitmask for compression type, timestamp type, transactional flags), timestamp (64-bit millisecond timestamp, CreateTime or LogAppendTime), offset delta (difference from base offset in the batch, varint), key length (length of key bytes, -1 for null key), key (optional key bytes used for partitioning and compaction), value length (length of value bytes, -1 for null value), value (the message payload bytes), headers count (number of header key-value pairs, varint), and headers (array of key-value pairs).

Records are batched into RecordBatch (previously called MessageSet). A batch contains multiple records, a CRC32 checksum, and batch-level metadata. Batching is critical for performance: a batch of 500 records reduces the per-record overhead from about 22 bytes to about 0.5 bytes. The batch is the atomic unit of storage, compression, and replication.

Topic Configuration

PropertyDefaultDescription
partitions1Number of partitions for parallelism
replication.factor1 (server default: 3)Number of replica copies
retention.ms604800000 (7 days)Maximum time to retain messages
retention.bytes-1 (unlimited)Maximum bytes to retain per partition
cleanup.policydeletedelete or compact
compression.typeproducerCompression used on the topic
message.max.bytes1048588 (1MB)Maximum record batch size
min.insync.replicas1Minimum ISR for write acknowledgement

The choice of partition count is one of the most important design decisions. Too few partitions limits consumer parallelism (each partition is consumed by at most one consumer in a group). Too many partitions increases overhead: more file handles, more memory-mapped index files, and more frequent leader elections. A good rule of thumb is: partitions = max(throughput_per_topic / 10_MBps, expected_consumer_count). For example, a topic receiving 100 MB/s and consumed by 20 consumers should have at least 20-30 partitions.

5. API Design � Producer & Consumer Protocols

Kafka uses a binary protocol over TCP. The protocol is versioned and backwards-compatible. All API calls follow a request-response pattern, though the consumer uses long-polling to minimize latency.

Producer API (C# Example)

Here is how a C# producer sends messages to a Kafka topic using the Confluent.Kafka library:

C#using Confluent.Kafka;

var config = new ProducerConfig
{
    BootstrapServers = "broker1:9092,broker2:9092,broker3:9092",
    Acks = Acks.All,
    LingerMs = 10,
    BatchSize = 65536,
    CompressionType = CompressionType.Snappy,
    EnableIdempotence = true,
    MessageSendMaxRetries = 3,
    RetryBackoffMs = 100,
    EnableDeliveryReports = true
};

using var producer = new ProducerBuilder<string, string>(config).Build();

for (int i = 0; i < 10000; i++)
{
    var msg = new Message<string, string>
    {
        Key = $"user_{i % 1000}",
        Value = $"{{\"event\":\"page_view\",\"userId\":{i % 1000},\"ts\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}}}"
    };
    var result = await producer.ProduceAsync("clickstream", msg);
    if (result.Status == PersistenceStatus.Persisted)
        Console.WriteLine($"Sent to partition {result.Partition} offset {result.Offset}");
}

The producer API handles batching, compression, retries, and partitioning transparently. Key design choices include Acks=All for strongest durability, LingerMs=10 to accumulate batches, and EnableIdempotence=true for exactly-once production guarantees via sequence numbers.

Consumer API (C# Example)

Consumers poll for new records in a loop with at-least-once semantics:

C#using Confluent.Kafka;

var config = new ConsumerConfig
{
    BootstrapServers = "broker1:9092,broker2:9092,broker3:9092",
    GroupId = "clickstream-analytics",
    AutoOffsetReset = AutoOffsetReset.Earliest,
    EnableAutoCommit = false,
    MaxPollIntervalMs = 300000,
    SessionTimeoutMs = 45000,
    HeartbeatIntervalMs = 3000,
    FetchMinBytes = 65536,
    FetchMaxWaitMs = 500,
    MaxPartitionFetchBytes = 1048576
};

using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe("clickstream");

try
{
    while (true)
    {
        var consumeResult = consumer.Consume(TimeSpan.FromMilliseconds(100));
        if (consumeResult == null) continue;
        var record = consumeResult.Message;
        Console.WriteLine($"Received: key={record.Key} value={record.Value} [p={consumeResult.Partition} o={consumeResult.Offset}]");
        consumer.Commit(consumeResult);
    }
}
catch (OperationCanceledException) { consumer.Close(); }

Key consumer design decisions: EnableAutoCommit=false gives manual offset control, MaxPollIntervalMs limits processing time before rebalance, and FetchMinBytes=65536 improves throughput by waiting for larger batches.

The Kafka protocol includes many more API calls: FetchRequest/FetchResponse for consumers, ProduceRequest/ProduceResponse for producers, OffsetFetchRequest for retrieving committed offsets, JoinGroup/Heartbeat/LeaveGroup for consumer group management, CreateTopics/DeleteTopics for administrative operations, and MetadataRequest for discovering brokers and topic metadata.

6. High-Level Architecture

Kafka architecture consists of several interacting components that together provide a distributed, fault-tolerant, and scalable event streaming platform.

graph TB
    subgraph "Kafka Cluster"
        C1[Controller Broker]
        C2[Broker 1]
        C3[Broker 2]
        C4[Broker 3]
        C5[Broker N]
        C1 -->|Manages metadata| C2
        C1 -->|Manages metadata| C3
        C1 -->|Manages metadata| C4
        C2 ---|Replication| C3
        C2 ---|Replication| C4
        C3 ---|Replication| C5
    end
    subgraph "KRaft Quorum"
        K1[Controller 1]
        K2[Controller 2]
        K3[Controller 3]
        K1 ---|Raft Consensus| K2
        K1 ---|Raft Consensus| K3
    end
    P1[Producer 1] -->|Produce| C2
    P2[Producer 2] -->|Produce| C3
    subgraph "Consumers"
        CG1[Consumer Group A]
        CG2[Consumer Group B]
    end
    C2 -->|Fetch| CG1
    C3 -->|Fetch| CG2
    SR[Schema Registry] -.->|Avro/Protobuf| P1
    SR -.->|Avro/Protobuf| CG1
    KC[Kafka Connect] -.->|Source/Sink| C2
    KS[Kafka Streams] -.->|Process| C3

Component Roles

ComponentRoleDetails
BrokerStorage and servingStores partition data, serves producer writes and consumer reads
ControllerMetadata managementOne broker acts as controller, managing partition leaders, ISR changes, reassignments
ZooKeeper/KRaftConsensus and metadata storeStores cluster metadata, broker registry, topic configs; KRaft replaces ZK with internal Raft
ProducerWrite recordsChooses partition (key hash or round-robin), batches records, sends to leader
ConsumerRead recordsPart of a consumer group, assigned partitions, polls for new data, commits offsets
Schema RegistrySchema managementExternal service, stores Avro/Protobuf schemas, validates producer/consumer schemas

Data Flow

The write path: a producer connects to any broker, which redirects it to the leader of the target partition (using MetadataRequest). The producer sends records to the leader, appending to the active segment. The leader acknowledges after the record is written to disk (and optionally after ISR replicas acknowledge). Followers replicate by sending FetchRequests to the leader, which streams the new records. On the read path: consumers send FetchRequests to partition leaders, specifying offset ranges. The broker reads from page cache (or disk for older data), and sends data using zero-copy sendfile() if the consumer supports it.

Real World: LinkedIn operates one of the largest Kafka deployments with over 1000 brokers, 100K+ partitions, and 7+ trillion messages per day. Their cluster handles 2GB/s peak ingress and is the backbone of their real-time data infrastructure powering feeds, search, and analytics.

7. Storage Engine � Log Segments and Indexing

Kafka storage engine is the key to its performance. Each partition is represented as a directory on the filesystem, containing segment files. Segments are the fundamental unit of storage, replication, and cleanup.

Directory Layout

Each partition directory contains: a data file named base_offset.log, an offset index named base_offset.index, a time index named base_offset.timeindex, a producer state snapshot file named base_offset.snapshot (for idempotence), and leader-epoch-checkpoint. The filename is the base offset, which is the offset of the first record in that segment. Segments are rolled when they reach a configurable size (default 1GB) or age (default 7 days). The active segment is the one currently being written to. Completed segments are immutable and eligible for compaction or deletion.

Indexing Strategy

Kafka maintains two index files per segment. The offset index maps (offset, position) pairs, where position is the byte offset within the .log file. Index entries are sparse: one entry every 4096 bytes by default (controlled by log.index.interval.bytes). To find an offset, Kafka does a binary search on the index to find the nearest lower offset, then scans from that position in the log file. This gives O(log N) lookup time where N is the number of index entries.

The time index maps (timestamp, offset) pairs. It has the same sparsity as the offset index. When a consumer wants to read from a specific timestamp, Kafka binary searches the time index to find the nearest timestamp, retrieves the corresponding offset, then uses the offset index to find the exact position. Both index files are memory-mapped (MappedByteBuffer in Java), providing fast access without syscalls. Each segment indexes typically require about 10MB per 1GB of data (0.1 percent overhead).

The use of memory-mapped files is critical for performance. Instead of allocating off-heap buffers explicitly, Kafka relies on the OS virtual memory system to manage the page cache. Data written to the log file ends up in the page cache. Index files, being memory-mapped, are also managed by the page cache. This means recent data and indexes are almost always in memory, providing fast access. When a consumer fetches recent data, Kafka can serve it directly from the page cache using sendfile(), without copying data into the JVM heap or even into user space.

C#public class KafkaSegment
{
    public long BaseOffset { get; set; }
    public long LastOffset { get; set; }
    public long Position { get; set; }
    public int MessageCount { get; set; }
    public long MaxTimestamp { get; set; }
    public bool IsActive { get; set; }
}

public class SegmentReader
{
    public static KafkaSegment ReadSegmentHeader(string logFilePath)
    {
        using var fs = File.OpenRead(logFilePath);
        var buffer = new byte[12 + 4 + 8 + 4 + 8];
        fs.Read(buffer, 0, buffer.Length);
        var baseOffset = BitConverter.ToInt64(buffer, 0);
        var batchLength = BitConverter.ToInt32(buffer, 8);
        return new KafkaSegment { BaseOffset = baseOffset, IsActive = false };
    }
}

8. Partitioning Strategy and Leader Election

Partitioning is the mechanism by which Kafka achieves scalability. Each topic is split into N partitions, each stored on potentially different brokers. Partitions are the unit of parallelism for both storage and consumption.

Partition Assignment

When a topic is created, the controller assigns partitions to brokers. The default partitioner assigns replicas to ensure even distribution across the cluster. For example, with 3 brokers and a topic with 6 partitions and RF=3, each broker hosts 2 leader partitions and 4 follower partitions. The controller uses a rack-aware algorithm that spreads replicas across racks for fault isolation.

Leader Election

The controller is responsible for electing partition leaders. Each partition has one leader and zero or more followers. Followers replicate from the leader to stay in sync. The leader handles all producer writes and consumer reads. When a broker fails, the controller detects the failure through ZooKeeper session expiry or KRaft heartbeat and performs the following steps: identifies all partitions whose leader was on the failed broker, for each partition selects a new leader from the in-sync replicas (ISR), updates ZooKeeper/KRaft with the new leader assignment, and sends LeaderAndIsr requests to the affected brokers.

C#public class Controller
{
    private Dictionary<string, List<PartitionState>> _partitions = new();

    public int ElectNewLeader(string topic, int partitionId)
    {
        var partition = _partitions[topic].First(p => p.PartitionId == partitionId);
        var candidate = partition.Isr
            .Where(brokerId => brokerId != partition.Leader)
            .OrderBy(_ => Random.Shared.Next())
            .FirstOrDefault();
        if (candidate == 0 && partition.AllReplicas.Count > 0)
            candidate = partition.AllReplicas.First(b => b != partition.Leader);
        if (candidate > 0)
        {
            partition.LeaderEpoch++;
            partition.Leader = candidate;
            return candidate;
        }
        throw new InvalidOperationException("No eligible leader found");
    }

    public record PartitionState
    {
        public int PartitionId { get; set; }
        public int Leader { get; set; }
        public List<int> Isr { get; set; } = new();
        public List<int> AllReplicas { get; set; } = new();
        public int LeaderEpoch { get; set; }
    }
}

The election is limited to ISR to prevent data loss. If no ISR replicas exist, Kafka can be configured to elect an out-of-sync replica by setting unclean.leader.election.enable to true, but this risks data loss. The trade-off is availability versus consistency.

Kafka supports preferred leader election, which attempts to restore the original leader assignment after a broker recovers. The first replica in the replica list is the preferred leader. This ensures balanced leadership distribution. You can enable auto-leader-rebalance to automatically trigger preferred leader elections.

Production Pitfall: Unclean leader election (electing a non-ISR replica as leader) causes data loss. Always set unclean.leader.election.enable=false for critical data. The trade-off is that the partition becomes unavailable until an ISR replica comes back online.

9. Replication, ISR and Durability Guarantees

Replication is Kafka mechanism for fault tolerance. Each partition data is replicated across a configurable number of brokers (replication factor, typically 3). The replication protocol is leader-follower based, with followers pulling data from the leader.

In-Sync Replicas (ISR)

The ISR is the set of replicas that are fully caught up with the leader. A replica is considered in-sync if it is within replica.lag.time.max.ms (default 30s, commonly set to 10s) of the leader. In modern Kafka (0.9+), the lag is defined by time, not message count. This prevents replicas from being spuriously removed from the ISR during traffic bursts, since the time-based check accounts for the follower possibly replicating slower but still making progress. When a follower fails or falls behind, it is removed from the ISR. If it recovers, it catches up by reading from its last known offset, and when it is within the lag threshold, the leader adds it back.

The ISR is stored in ZooKeeper/KRaft and is part of the partition metadata. The producer acks setting interacts directly with the ISR: acks=0 (fire and forget, no acknowledgement), acks=1 (leader acknowledges after local write), acks=all (leader acknowledges after all ISR replicas acknowledge). For acks=all, the highest durability is achieved.

acksBehaviorDurabilityLatency
0Fire and forget, no acknowledgementLowest (may lose data)Lowest
1Leader acknowledges after local writeMedium (loses if leader fails before replication)Low
allLeader acknowledges after all ISR replicas ackHighest (no data loss)

When acks=all, the producer waits for acknowledgement from all ISR replicas. If the ISR shrinks to fewer than min.insync.replicas, the leader stops accepting acks=all writes and returns an error. This prevents the system from accepting writes that cannot be fully replicated. For a 3-replica topic, set min.insync.replicas=2. This ensures that at least 2 replicas acknowledge every write, so a single broker failure does not cause any data loss.

Data Consistency Guarantees

Kafka provides durable writes (a message acknowledged with acks=all is guaranteed persisted on all ISR replicas), ordered within partition (messages maintain order, and the idempotent producer prevents duplicates on retry), consistent reads (consumers see monotonic reads, never seeing an offset less than one already observed), and leader election safety (the leader epoch ensures that a replica not in the ISR cannot produce messages that conflict with previously acknowledged messages).

The leader epoch mechanism is worth elaborating on. Each time a leader election occurs, the leader epoch is incremented. Followers track the leader epoch along with their high watermark. When a leader change happens, the new leader truncates its log to the offset corresponding to the previous epoch. This ensures that even if a replica that was not in the ISR becomes the new leader, it does not reintroduce messages that were acknowledged and then lost.

10. Consumer Groups, Offsets and Rebalancing

Consumer groups are Kafka mechanism for scaling consumption. Each consumer in a group is assigned a subset of the partitions of the topics it subscribes to. Within a group, each partition is consumed by exactly one consumer, which ensures ordered processing per partition. Multiple consumer groups can independently consume the same topic, each with its own offset positions.

Group Coordination Protocol

When a consumer starts, it sends a JoinGroup request to the group coordinator (a broker determined by hashing the group ID). The coordinator maintains the group membership and partition assignments. The protocol has three main phases: Join (consumers send JoinGroup with their subscription, the coordinator selects a group leader and sends the member list), Sync (the leader creates a partition assignment using a configurable strategy and sends it back), and Heartbeat (consumers periodically send Heartbeat requests to signal they are alive).

Offset Management

Offsets are committed to a compacted internal topic called __consumer_offsets (50 partitions by default). Each commit contains the group ID, topic, partition, and offset. The compacted nature of this topic means only the latest committed offset per (group, topic, partition) is retained. Consumers can auto-commit periodically or manually commit after processing for finer control.

Rebalance Strategies

StrategyBehaviorUse Case
RangeAssigns contiguous ranges of partitionsSimple but may be imbalanced
RoundRobinRound-robin across consumersMore balanced
StickyMin partition movement during rebalanceReduces reprocessing
CooperativeStickyIncremental, only revoked partitions moveBest for large topologies

The CooperativeSticky rebalancer (KIP-429, Kafka 2.4+) is the recommended strategy for production. Unlike eager rebalances which revoke all partitions from all consumers, cooperative rebalancing revokes only the partitions that need to move. This reduces the window where no consumer is processing certain partitions, improving overall throughput during rolling restarts.

C#public class KafkaRebalanceListener : IConsumerRebalanceListener
{
    private readonly IConsumer<string, string> _consumer;
    private readonly Dictionary<TopicPartition, long> _currentOffsets = new();

    public KafkaRebalanceListener(IConsumer<string, string> consumer)
    {
        _consumer = consumer;
    }

    public void PartitionsRevoked(ICollection<TopicPartitionOffset> partitions)
    {
        Console.WriteLine($"Partitions revoked: {string.Join(", ", partitions)}");
        _consumer.Commit(_currentOffsets.Select(kv =>
            new TopicPartitionOffset(kv.Key, kv.Value + 1)));
        _currentOffsets.Clear();
    }

    public void PartitionsAssigned(ICollection<TopicPartition> partitions)
    {
        Console.WriteLine($"Partitions assigned: {string.Join(", ", partitions)}");
        foreach (var partition in partitions)
        {
            var offset = _consumer.Committed(partition);
            var startOffset = offset?.Offset ?? 0;
            _consumer.Seek(new TopicPartitionOffset(partition, startOffset));
            _currentOffsets[partition] = startOffset - 1;
        }
    }
}

11. Delivery Semantics � At-Most-Once, At-Least-Once, Exactly-Once

Kafka offers three delivery semantics, each with distinct trade-offs between performance, complexity, and correctness. Understanding these is critical for system design interviews and production deployments.

At-Most-Once

In this mode, messages may be lost but are never duplicated. The consumer commits the offset before processing the message. If the consumer crashes after committing but before processing, the message is lost. This is the fastest but least reliable mode. Use case: non-critical metrics, logging where occasional data loss is acceptable.

C#// At-most-once: commit before processing
var config = new ConsumerConfig { EnableAutoCommit = true, AutoOffsetReset = AutoOffsetReset.Earliest };
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe("metrics");
while (true)
{
    var result = consumer.Consume();
    // Offset already committed before we process!
    ProcessMessage(result.Message); // Crash here = message lost
}

At-Least-Once

The consumer processes the message first, then commits the offset. If the consumer crashes before committing, the message is redelivered. This is the most common mode. It guarantees no data loss but allows duplicates. Applications must handle deduplication or be tolerant of it.

C#// At-least-once: process then commit
var config = new ConsumerConfig { EnableAutoCommit = false, AutoOffsetReset = AutoOffsetReset.Earliest };
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe("orders");
while (true)
{
    var result = consumer.Consume();
    ProcessOrder(result.Message);
    consumer.Commit(result); // Crash here = redelivery
}

Exactly-Once Semantics (EOS)

Exactly-once means messages are processed exactly one time, no data loss and no duplicates. Kafka achieves this through three mechanisms: idempotent producer, transactions, and transactional offset commits. The idempotent producer assigns a Producer ID and sequence number to each message; brokers track this and reject duplicates. Transactions allow grouping multiple writes across different partitions into an atomic bundle. Transactional offset commits ensure that consumption offsets are committed within the same transaction as the output writes.

C#// Exactly-once: transactional consumer-producer
var producerConfig = new ProducerConfig
{
    BootstrapServers = "broker:9092",
    TransactionalId = "order-processor-1",
    EnableIdempotence = true,
    Acks = Acks.All
};
using var producer = new ProducerBuilder<string, string>(producerConfig).Build();
producer.InitTransactions(TimeSpan.FromSeconds(30));

var consumerConfig = new ConsumerConfig
{
    BootstrapServers = "broker:9092",
    GroupId = "order-processor",
    EnableAutoCommit = false,
    IsolationLevel = IsolationLevel.ReadCommitted,
    AutoOffsetReset = AutoOffsetReset.Earliest
};

using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
consumer.Subscribe("input-orders");

while (true)
{
    var result = consumer.Consume();
    if (result == null) continue;

    try
    {
        producer.BeginTransaction();
        var output = ProcessOrder(result.Message);
        await producer.ProduceAsync("processed-orders", new Message<string, string>
        {
            Key = output.Id, Value = output.Json
        });
        producer.SendOffsetsToTransaction(
            new[] { new TopicPartitionOffset(result.TopicPartition, result.Offset + 1) },
            consumer.ConsumerGroupMetadata);
        producer.CommitTransaction();
    }
    catch (Exception)
    {
        producer.AbortTransaction();
    }
}

The isolation level ReadCommitted ensures consumers only see records from committed transactions. Records from aborted or in-progress transactions are not visible. This prevents consumers from reading data that may later be rolled back.

12. Performance Optimization and Zero-Copy

Kafka performance is legendary, capable of millions of messages per second on modest hardware. This performance comes from several key design decisions that optimize for modern hardware characteristics.

Sequential I/O

The most fundamental optimization is the use of sequential I/O. Kafka writes all new data by appending to the end of the active segment. There is no random writing, no in-place updates. On modern NVMe drives, sequential writes achieve 3-6 GB/s while random writes might only achieve 100-500 MB/s. By making all writes append-only, Kafka saturates the storage bandwidth. Similarly, consumer reads are sequential. A consumer reads a range of offsets sequentially from the log file. Even though consumers might have different read positions, Kafka can serve them all from the page cache if the data is recent enough.

Page Cache Utilization

Kafka does not cache messages in the JVM heap beyond what is necessary for buffering. Instead, it relies on the OS page cache. When a producer writes data, the data enters the page cache. If a consumer reads the same data shortly after, Kafka can serve it directly from the page cache without ever entering the JVM heap. This has several advantages: no JVM GC overhead for cached data, automatic memory management by the OS, free cache for all consumers, and zero-copy transfer from page cache to network card.

Zero-Copy sendfile()

The zero-copy optimization is perhaps the most impactful. Traditional data transfer from disk to network socket involves multiple data copies and context switches: the hard drive reads data into the kernel buffer, then copies it to the application buffer (in the JVM heap), then the application copies it to the socket buffer in the kernel, which finally sends it to the network card. Zero-copy eliminates the application buffer copies entirely. The data path in zero-copy mode: the disk controller reads data into the kernel page cache via DMA (no CPU involvement). The sendfile() syscall instructs the kernel to send data from the page cache directly to the network card socket buffer, also via DMA. The CPU is involved only for the initial setup. Benchmarks show that zero-copy provides 2-5x throughput improvement over traditional read-send cycles for large data transfers.

C#// Zero-copy: sendfile() concept in .NET
public static class ZeroCopyHelper
{
    [DllImport("libc", SetLastError = true)]
    private static extern long sendfile(int out_fd, int in_fd, ref long offset, long count);

    public static long SendFileToSocket(SafeFileHandle socket, SafeFileHandle file, long offset, long count)
    {
        return sendfile(socket.DangerousGetHandle().ToInt32(),
                       file.DangerousGetHandle().ToInt32(),
                       ref offset, count);
    }
}

Compression

Kafka supports compression at the producer level (records are compressed into batches before being sent to the broker) and at the topic level. Compression ratios vary by algorithm: gzip provides about 4.5x compression at 50 MB/s, snappy provides about 3x at 250 MB/s, lz4 provides about 3.5x at 300 MB/s, and zstd provides about 4x at 150 MB/s. For most use cases, lz4 or zstd provide the best balance of compression ratio and speed. Snappy is good when CPU is the bottleneck. gzip provides the best compression but is significantly slower. The producer compresses batches before sending, and the broker stores them compressed. Consumers decompress during deserialization.

AlgorithmCompression RatioCompress Speed (MB/s)Decompress Speed (MB/s)
gzip4.5x50200
snappy3.0x250500
lz43.5x300600
zstd4.0x150450

13. Producer and Consumer Batching Mechanics

Batching is the single most important knob for tuning Kafka performance. Messages are always written and read in batches. Understanding the batching architecture helps in diagnosing performance issues and configuring clusters correctly.

Producer Batching

The producer maintains a BufferPool for each broker. When a producer sends a message, it is serialized and placed into a RecordBatch in the partition queue. The producer accumulates batches in memory and sends them when either the batch reaches batch.size (default 16KB, recommend 64KB-1MB for high throughput) or the linger time (linger.ms, default 0) has elapsed since the first message was added. A linger.ms of 0 means the producer sends immediately when the batch is complete. For high-throughput workloads, setting linger.ms to 5-20ms significantly improves batching efficiency. The trade-off is slightly increased latency.

C#// Producer configuration for optimal batching
var config = new ProducerConfig
{
    BootstrapServers = "broker:9092",
    BatchSize = 131072,
    LingerMs = 5,
    BufferMemory = 33554432,
    CompressionType = CompressionType.Zstd,
    EnableIdempotence = true,
    MaxInFlight = 5
};

// The producer automatically handles threading:
// Application thread serializes and enqueues messages
// I/O thread drains partition queues and sends to broker
// Response handler processes acks and delivery reports

The producer I/O thread maintains one connection per broker, not per partition. This means the number of TCP connections scales with the number of brokers, not the number of partitions, which is critical for clusters with thousands of partitions.

Consumer Batching

Consumers also batch their fetches. The fetch.min.bytes parameter (default 1) controls how much data the consumer wants in a single fetch response. Setting this to 64KB or higher forces the broker to accumulate data before responding, reducing the number of fetch requests and improving throughput. The fetch.max.wait.ms parameter (default 500) sets the maximum time the broker will wait to fulfill fetch.min.bytes. The consumer max.poll.records (default 500) controls how many records are returned per poll call.

The combination of fetch batching and poll batching gives fine-grained control: use fetch.min.bytes=65536 and fetch.max.wait.ms=500 for throughput, or fetch.min.bytes=1 and fetch.max.wait.ms=10 for low latency at the cost of more fetch requests. Benchmarks show that proper batching yields 10-15x throughput improvement over unbuffered production.

ConfigurationThroughput (MB/s)p99 Latency (ms)CPU Utilization
No batching, no compression45285%
Batch=64KB, linger=10ms, no compression3801260%
Batch=128KB, linger=10ms, lz45201545%
Batch=512KB, linger=20ms, zstd6802535%

14. Log Compaction and Cleanup Policies

Kafka supports two cleanup policies for log segments: delete and compact. Understanding both is essential for designing topics that align with your data retention requirements.

Delete Policy

The delete policy removes segments based on age (retention.ms) or total partition size (retention.bytes). Segments that are fully outside the retention window are deleted. The active segment is never deleted; Kafka waits for it to complete before considering it for deletion. This means actual retention may be slightly longer than configured, up to one segment worth of additional data.

Compact Policy

Log compaction ensures that the log retains at least the last known value for each record key. It is designed for topics that store a changelog or state like the __consumer_offsets topic. Compaction runs in the background via the log cleaner threads. The process reads a segment file, builds a map of keys to the latest offset for that key, writes a new segment file containing only the latest value per key, and swaps the old segment for the new one. Compaction does not remove the last occurrence of a key. Keys with null values are treated as tombstone markers indicating the key should be deleted. During compaction, both the tombstone and any prior values for that key are removed.

C#public class LogCompactor
{
    public void Compact(string partitionDir)
    {
        var segments = Directory.GetFiles(partitionDir, "*.log")
            .Select(f => new FileInfo(f))
            .OrderBy(f => f.Name)
            .ToList();

        foreach (var segment in segments.Take(segments.Count - 1))
        {
            var compactedPath = Path.Combine(partitionDir, $"{segment.Name}.compacted");
            using var reader = File.OpenRead(segment.FullName);
            using var writer = File.OpenWrite(compactedPath);

            // Phase 1: Build latest offset per key
            var latestPerKey = new Dictionary<string, long>();
            foreach (var batch in ReadBatches(reader))
                foreach (var record in batch.Records)
                    if (record.Key != null)
                        latestPerKey[record.Key] = record.Offset;

            // Phase 2: Write only latest value per key
            reader.Seek(0, SeekOrigin.Begin);
            foreach (var batch in ReadBatches(reader))
            {
                var filtered = batch.Records
                    .Where(r => r.Key == null || latestPerKey.GetValueOrDefault(r.Key) == r.Offset)
                    .ToList();
                if (filtered.Count > 0)
                    WriteBatch(writer, batch.BaseOffset, filtered);
            }

            File.Delete(segment.FullName);
            File.Move(compactedPath, segment.FullName);
        }
    }
}

Combined Policy

Topics can use both policies simultaneously: cleanup.policy=compact,delete. In this mode, the log is both compacted and deleted. This is useful for topics like user profiles where you want to keep the latest state but still have a retention bound. For example, with a 7-day retention and compaction, user profile updates over the last 7 days are retained, but the latest value per key is preserved even if earlier segments are deleted.

15. KRaft Mode � ZooKeeper Elimination

KRaft (Kafka Raft Metadata mode), introduced as KIP-500, eliminates Kafka dependency on ZooKeeper. Instead of using an external system for consensus, Kafka runs its own Raft-based quorum of controller nodes to manage cluster metadata. This is perhaps the most significant architectural change in Kafka history.

Why KRaft?

ZooKeeper was a bottleneck in several ways. ZooKeeper write throughput limits partition scaling. Each partition metadata change requires a ZooKeeper write, and ZK handles a few thousand writes per second, capping partition count at around 100K per cluster. ZooKeeper leader elections and session timeouts add latency to controller failover, often taking 30+ seconds. Running a ZooKeeper ensemble alongside the Kafka cluster doubles the operational burden. And with ZK, Kafka still has a single active controller which is a potential bottleneck.

KRaft Architecture

KRaft replaces ZooKeeper with a Raft-based quorum of controller nodes. These controllers maintain a metadata log that records all cluster metadata changes: broker registrations, topic configurations, partition assignments, leader changes, and so on. A controller is elected as the active leader, and its metadata log is replicated to the other controllers via Raft consensus. Brokers fetch metadata from the active controller via a lightweight protocol. This design eliminates the ZK bottleneck, supports 1M+ partitions theoretically, provides sub-second controller failover, and reduces operational complexity.

graph LR
    subgraph "KRaft Controllers (Raft Quorum)"
        C1[Controller 1 - Leader]
        C2[Controller 2 - Follower]
        C3[Controller 3 - Follower]
        C1 -->|Raft Log Replication| C2
        C1 -->|Raft Log Replication| C3
    end
    subgraph "Brokers (Data Plane)"
        B1[Broker 1]
        B2[Broker 2]
        B3[Broker N]
    end
    C1 -->|Metadata Fetch| B1
    C1 -->|Metadata Fetch| B2
    C1 -->|Metadata Fetch| B3
    B1 -->|Heartbeat| C1
    B2 -->|Heartbeat| C1
    B3 -->|Heartbeat| C1
AspectZooKeeper ModeKRaft Mode
ConsensusExternal, ZAB protocolInternal, Raft protocol
Partition limit~100K1M+ (theoretical)
Failover time6-30 secondsless than 1 second
Components to manageKafka + ZK (3-5 nodes)Kafka only
Metadata storageZooKeeper znodesMetadata log partitions

Apache Kafka 3.x supports migration from ZK to KRaft. The process involves starting a KRaft controller quorum alongside the existing cluster, running a migration tool that copies metadata from ZK to the KRaft metadata log, flipping a flag to switch to KRaft mode, and removing the ZK dependency. The migration is rolling and designed to be zero-downtime.

16. Transactions, Idempotence and E2E Exactly-Once

Kafka transaction support enables atomic writes across multiple partitions and topics, with exactly-once semantics end-to-end. This is a complex but powerful feature that we cover in depth here.

Idempotent Producer

The idempotent producer is the foundation of EOS. Each producer instance is assigned a unique Producer ID (PID) upon initialization. The producer also maintains a per-partition sequence number, starting from 0 and monotonically increasing with each batch sent. The broker tracks the last five sequence numbers per partition. If the producer retries a batch, the broker checks the sequence number. If it matches the last acknowledged sequence number, the broker rejects the duplicate. If it is an older sequence number, the producer receives an out-of-order sequence error.

Transactions

Transactions extend idempotence to allow atomic multi-partition writes. The transaction protocol involves a Transaction Coordinator, a broker designated to handle transactions for a given transactional.id. The coordinator persists transaction state to an internal topic called __transaction_state. The producer initiates a transaction with InitTransactions, begins a transaction, sends batches (which are staged but not visible to read_committed consumers), and then either commits or aborts. On commit, the coordinator writes commit markers to each partition that participated, making the data visible. On abort, the staged records are never made visible. The transaction timeout (default 60s) protects against hung producers, automatically aborting if the producer crashes without committing or aborting.

The most common pattern for end-to-end exactly-once processing is the consume-process-produce loop with transactional offset commits. The critical insight is that the offset commit participates in the same transaction as the output write. If the output write fails, the offset is not committed, so the input message is redelivered. If the offset commit fails but the output write succeeds, the transaction is aborted, rolling back the output write. This ensures atomicity between consumption and production.

C#public class ExactlyOnceProcessor
{
    private readonly string _bootstrapServers;
    private readonly string _groupId;
    private readonly string _transactionalId;

    public ExactlyOnceProcessor(string bootstrapServers, string groupId, string transactionalId)
    {
        _bootstrapServers = bootstrapServers;
        _groupId = groupId;
        _transactionalId = transactionalId;
    }

    public async Task Run(string inputTopic, string outputTopic, CancellationToken ct)
    {
        var producerConfig = new ProducerConfig
        {
            BootstrapServers = _bootstrapServers,
            TransactionalId = _transactionalId,
            EnableIdempotence = true,
            Acks = Acks.All,
            LingerMs = 5,
            BatchSize = 65536
        };

        using var producer = new ProducerBuilder<string, string>(producerConfig).Build();
        producer.InitTransactions(TimeSpan.FromSeconds(30));

        var consumerConfig = new ConsumerConfig
        {
            BootstrapServers = _bootstrapServers,
            GroupId = _groupId,
            EnableAutoCommit = false,
            IsolationLevel = IsolationLevel.ReadCommitted,
            AutoOffsetReset = AutoOffsetReset.Earliest
        };

        using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
        consumer.Subscribe(inputTopic);

        while (!ct.IsCancellationRequested)
        {
            try
            {
                var result = consumer.Consume(ct);
                if (result == null) continue;

                producer.BeginTransaction();
                var outputValue = Transform(result.Message.Value);
                await producer.ProduceAsync(outputTopic,
                    new Message<string, string> { Key = result.Message.Key, Value = outputValue });

                producer.SendOffsetsToTransaction(
                    new[] { new TopicPartitionOffset(result.TopicPartition, result.Offset + 1) },
                    consumer.ConsumerGroupMetadata);

                producer.CommitTransaction();
            }
            catch (Exception)
            {
                try { producer.AbortTransaction(); } catch { }
            }
        }
    }

    private static string Transform(string input) => input.ToUpperInvariant();
}

For applications that write to external systems like databases rather than Kafka, achieving exactly-once is harder. The typical pattern is to store the Kafka offset alongside the business data in the external system using the same database transaction, then compare offsets on restart to skip already-processed messages. This is known as the transactional outbox pattern.

17. Kafka Connect and Stream Processing

Kafka Connect and Kafka Streams extend Kafka from a messaging system into a complete data integration and stream processing platform.

Kafka Connect

Kafka Connect is a framework for moving data between Kafka and external systems. Connectors run as worker processes in a Connect cluster. Source connectors read from external systems and write to Kafka topics. Examples include JDBC (read database tables), Debezium (CDC from MySQL, PostgreSQL, MongoDB), and FileStream. Sink connectors read from Kafka topics and write to external systems. Examples include Elasticsearch Sink, S3 Sink, HDFS Sink, and JDBC Sink. Connect uses a REST API for management and handles offset tracking, schema evolution, and exactly-once delivery.

Real World: Debezium, a CDC source connector, is one of the most popular Kafka Connect connectors. It captures row-level changes from database transaction logs and streams them to Kafka topics. This enables event-driven microservices, cache invalidation, search index updates, and real-time analytics without custom polling logic.

Kafka Streams

Kafka Streams is a client library (not a separate cluster) for building stateful stream processing applications. It runs in your application and provides stateless operations (map, filter, flatMap), stateful operations (groupBy, aggregate, reduce, count, windowedBy, join), state stores (RocksDB-based key-value stores backed by Kafka changelog topics), and exactly-once processing semantics. Kafka Streams is Java-native, but .NET developers can use KafkaFlow or implement similar patterns with Confluent.Kafka.

C#// Stream aggregation pattern with Confluent.Kafka
public class StreamAggregator
{
    private readonly Dictionary<string, Dictionary<long, long>> _windowedCounts = new();
    private readonly TimeSpan _windowSize = TimeSpan.FromMinutes(1);

    public async Task ProcessClickstream(IConsumer<string, string> consumer,
        IProducer<string, string> producer, CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var result = consumer.Consume(ct);
            if (result == null) continue;

            var userId = result.Message.Key;
            var windowStart = GetWindowStart(result.Message.Timestamp.UtcDateTime);

            if (!_windowedCounts.ContainsKey(userId))
                _windowedCounts[userId] = new Dictionary<long, long>();
            if (!_windowedCounts[userId].ContainsKey(windowStart))
                _windowedCounts[userId][windowStart] = 0;

            _windowedCounts[userId][windowStart]++;

            await producer.ProduceAsync("pageview-counts", new Message<string, string>
            {
                Key = userId,
                Value = $"{{\"userId\":\"{userId}\",\"count\":{_windowedCounts[userId][windowStart]}}}"
            });

            if (result.Offset % 100 == 0)
                CleanupExpiredWindows();

            consumer.Commit(result);
        }
    }

    private static long GetWindowStart(DateTime timestamp)
    {
        var aligned = new DateTime(timestamp.Year, timestamp.Month, timestamp.Day,
            timestamp.Hour, timestamp.Minute, 0, DateTimeKind.Utc);
        return ((DateTimeOffset)aligned).ToUnixTimeMilliseconds();
    }

    private void CleanupExpiredWindows()
    {
        var cutoff = DateTimeOffset.UtcNow.Add(-_windowSize * 2).ToUnixTimeMilliseconds();
        foreach (var user in _windowedCounts.Keys.ToList())
        {
            var expired = _windowedCounts[user].Where(kv => kv.Key < cutoff).Select(kv => kv.Key).ToList();
            foreach (var w in expired)
                _windowedCounts[user].Remove(w);
        }
    }
}

Common Kafka Streams patterns include stream-table join (enriching events with reference data), windowed aggregations (counting events in tumbling or hopping windows), and materialized views (maintaining real-time aggregate tables). KSQL (ksqlDB) provides a SQL interface to stream processing, compiling SQL into Kafka Streams topologies under the hood.

18. Security � TLS, SASL, ACLs and Encryption

Enterprise Kafka deployments require robust security. Kafka supports encryption in transit, authentication, authorization, and audit logging.

Encryption in Transit

TLS encrypts all communication between clients and brokers and between brokers. Configuring TLS involves generating CA and broker/client certificates, configuring ssl.keystore.location and ssl.truststore.location on brokers, enabling SSL listeners, and configuring clients with security.protocol=SSL. For mutual TLS (mTLS), both broker and client present certificates, providing the highest security level and eliminating the need for SASL-based authentication.

Authentication

Kafka supports several SASL mechanisms: SASL/PLAIN (simple username/password, low security, use only with TLS), SASL/SCRAM-SHA-256/512 (salt-based password stored hashed, medium security), SASL/GSSAPI (Kerberos integration for enterprise environments), SASL/OAUTHBEARER (OAuth 2.0 tokens, integrates with identity providers), and mTLS (certificate-based, highest security).

Authorization with ACLs

Kafka ACLs follow the pattern: Principal P is Allowed/Denied Operation O from Host H on Resource R. Resources include topics, consumer groups, clusters, transactional IDs, and delegation tokens. ACLs are managed using the kafka-acls.sh CLI tool or programmatically through AdminClient.

C#public class KafkaAclManager
{
    private readonly IAdminClient _adminClient;

    public KafkaAclManager(string bootstrapServers)
    {
        _adminClient = new AdminClientBuilder(new AdminClientConfig
        { BootstrapServers = bootstrapServers }).Build();
    }

    public async Task GrantConsumerAccess(string groupId, string principal, string topic)
    {
        var groupAcl = new AclBinding(
            new Resource(ResourceType.Group, groupId, ResourcePatternType.Literal),
            new AccessControlEntry(principal, "*", AclOperation.Read, AclPermissionType.Allow));
        var readAcl = new AclBinding(
            new Resource(ResourceType.Topic, topic, ResourcePatternType.Literal),
            new AccessControlEntry(principal, "*", AclOperation.Read, AclPermissionType.Allow));
        var describeAcl = new AclBinding(
            new Resource(ResourceType.Topic, topic, ResourcePatternType.Literal),
            new AccessControlEntry(principal, "*", AclOperation.Describe, AclPermissionType.Allow));

        var result = await _adminClient.CreateAclsAsync(new[] { groupAcl, readAcl, describeAcl });
        foreach (var r in result)
            if (r.Error.IsError)
                Console.Error.WriteLine($"Failed to create ACL: {r.Error.Reason}");
    }
}

Encryption at Rest

Kafka supports encryption at rest through filesystem-level encryption (using LUKS, BitLocker, or dm-crypt on the broker data directories) or per-topic encryption using interceptors. For compliance with regulations like GDPR, HIPAA, or PCI-DSS, Kafka TLS + SASL/SCRAM + ACLs + audit logging provide the necessary controls.

19. Monitoring, Metrics and Observability

A robust monitoring strategy is essential for operating a Kafka cluster. The key metrics fall into several categories.

Health Metrics

UnderReplicatedPartitions (alert at greater than 0, immediately) indicates partitions with fewer replicas than the configured replication factor. OfflinePartitionsCount (critical alert at greater than 0) indicates partitions with no active leader. ActiveControllerCount should be exactly 1 per cluster.

Throughput and Latency

BytesInPerSec and BytesOutPerSec track broker bandwidth. Alert when exceeding 80 percent of network capacity. MessagesInPerSec tracks production rate. RequestTotalTimeMs (p99) should be less than 100ms. LocalTimeMs (p99) should be less than 20ms. RemoteTimeMs (p99) should be less than 50ms.

Storage and Consumer Metrics

Monitor LogEndOffset for growth rate and LogSegmentCount for compaction health. ConsumerLag is the most important consumer metric, indicating how far behind a consumer is. Alert when lag exceeds 10000 messages or when MaxGroupLag exceeds 100000.

CategoryMetricAlert Threshold
HealthUnderReplicatedPartitionsgreater than 0
HealthOfflinePartitionsCountgreater than 0
HealthActiveControllerCountnot equal to 1
ThroughputBytesInPerSecgreater than 80% network
LatencyRequestTotalTimeMs p99greater than 100ms
StorageDisk utilizationgreater than 80%
JVMHeap usagegreater than 85%
ConsumerConsumerLaggreater than 10000

The standard monitoring stack includes JMX Exporter (exposes Kafka JMX metrics to Prometheus), Prometheus (scrapes metrics), Grafana (dashboards), and kafka_exporter (consumer lag). Beyond system metrics, monitor the Kafka request pipeline phases: QueueTimeMs (request waiting time), LocalTimeMs (leader processing time), RemoteTimeMs (ISR wait time), and ThrottleTimeMs (quota limit).

20. Tiered Storage � Moving Warm Data to Object Stores

Tiered storage (KIP-405) is a major feature that separates hot and cold data. Hot data resides on local broker storage (NVMe/SSD). Cold data is automatically moved to cheaper object storage (S3, GCS, HDFS). This dramatically reduces storage costs for long-retention Kafka clusters.

Architecture

In tiered storage mode, Kafka log is split into two parts: the local log (active segment and recently completed segments on local disk, providing fast access) and the remote log (older segments uploaded to object storage). Consumers can fetch data from both local and remote segments transparently. The remote log manager handles uploading local segments to object storage and tracks uploaded segments in a metadata topic.

Benefits

Tiered storage provides cost reduction (object storage costs about $0.02/GB/month versus $0.10-0.20/GB/month for local NVMe, saving over $1M/year for a 1PB cluster), unlimited retention (months or years without planet-scale local storage), and simplified operations (less disk space management).

C#public class TieredStorageUploader
{
    private readonly string _localLogDir;
    private readonly string _bucketName;

    public TieredStorageUploader(string localLogDir, string bucketName)
    {
        _localLogDir = localLogDir;
        _bucketName = bucketName;
    }

    public async Task RunAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await UploadCompletedSegments();
            await Task.Delay(60000, ct);
        }
    }

    private async Task UploadCompletedSegments()
    {
        var partitionDirs = Directory.GetDirectories(_localLogDir, "*-*");
        foreach (var partitionDir in partitionDirs)
        {
            var segments = Directory.GetFiles(partitionDir, "*.log")
                .Select(f => new FileInfo(f))
                .Where(f => DateTime.UtcNow - f.LastWriteTimeUtc > TimeSpan.FromMinutes(1))
                .OrderBy(f => f.Name)
                .ToList();

            foreach (var segment in segments)
            {
                var remotePath = $"{Path.GetFileName(partitionDir)}/{segment.Name}";
                await UploadToObjectStore(segment.FullName, remotePath);
                Console.WriteLine($"Uploaded {remotePath}");
            }
        }
    }

    private Task UploadToObjectStore(string localPath, string remotePath)
    {
        Console.WriteLine($"Uploading {localPath} to s3://{_bucketName}/{remotePath}");
        return Task.CompletedTask;
    }
}

Tiered storage is available in Confluent Platform and is being actively developed in Apache Kafka. When fully mature, it will be considered essential for any large-scale Kafka deployment, reducing storage costs by 5-10x while keeping the same consumer API and semantics.

21. Multi-Region and Cross-Datacenter Replication

For global deployments, Kafka data must be replicated across data centers or cloud regions. The primary tool for this is MirrorMaker 2, though Confluent offers a more sophisticated Multi-Region Cluster (MRC) solution.

MirrorMaker 2 Architecture

MirrorMaker 2 is a Kafka Connect-based tool that replicates topics between clusters. It runs as a Connect cluster with special mirror connectors. Key features include topic replication (all or selected topics from a source cluster are replicated to a destination cluster), consumer group offset sync (enabling failover to the destination cluster without data loss), active-active replication (bidirectional replication with conflict detection based on timestamps), and topic renaming (topics appear as source.topic.name in the destination to prevent conflicts).

Disaster Recovery Strategies

There are three common multi-region Kafka architectures. Active-Passive is the simplest: one cluster handles all production and consumption, and MirrorMaker 2 replicates to a standby cluster. On failover, consumers switch to the standby cluster. Failover time is determined by how long it takes to detect the failure and switch DNS. Active-Active uses bidirectional replication with both clusters handling writes and reads. Conflict resolution is needed for the same key being written in both regions. Stretch Cluster is the most sophisticated: a single Kafka cluster spans multiple data centers using high-latency links. Brokers in different DCs participate in the same ISR. This provides strong consistency across DCs but requires low-latency links (less than 10ms RTT).

C#public class MultiRegionConsumer
{
    private readonly string[] _clusters;
    private IConsumer<string, string> _currentConsumer;
    private int _currentClusterIndex;

    public MultiRegionConsumer(string[] bootstrapServers, string groupId)
    {
        _clusters = bootstrapServers;
        _currentClusterIndex = 0;
        _currentConsumer = CreateConsumer(_clusters[_currentClusterIndex], groupId);
    }

    private static IConsumer<string, string> CreateConsumer(string bootstrap, string groupId)
        => new ConsumerBuilder<string, string>(new ConsumerConfig
        {
            BootstrapServers = bootstrap,
            GroupId = groupId,
            EnableAutoCommit = false,
            AutoOffsetReset = AutoOffsetReset.Earliest
        }).Build();

    public ConsumeResult<string, string> Consume(CancellationToken ct)
    {
        try
        {
            return _currentConsumer.Consume(ct);
        }
        catch (KafkaException)
        {
            return FailoverToNextCluster();
        }
    }

    private ConsumeResult<string, string> FailoverToNextCluster()
    {
        _currentConsumer.Close();
        _currentClusterIndex = (_currentClusterIndex + 1) % _clusters.Length;
        _currentConsumer = CreateConsumer(_clusters[_currentClusterIndex], _currentConsumer.Name);
        Console.WriteLine($"Failed over to cluster {_clusters[_currentClusterIndex]}");
        return _currentConsumer.Consume();
    }
}

22. Scaling Strategies and Cluster Expansion

Scaling a Kafka cluster requires careful planning to maintain performance and availability. There are several strategies for scaling.

Adding Brokers

Adding brokers to a running cluster is a common operation. Once a new broker joins, it registers with the controller and starts receiving metadata updates. However, new brokers will not have any partition replicas until partition reassignment moves data to them. Use kafka-reassign-partitions to generate a plan and execute it. The process: generate a plan, the controller begins reassignment by adding new replicas, waiting for them to join the ISR, then removing old replicas. Reassignment increases network load, so do it in small batches (500 partitions at a time) during low traffic.

Increasing Partitions

Increasing partitions for a topic is supported but should be done carefully. Once partitions are added, the key-based partitioning order changes: messages with the same key that were previously in partition X may now go to a different partition. This breaks compaction semantics and ordering guarantees for existing keys. A safer approach is to create a new topic with more partitions and migrate consumers to it.

Consumer Scaling

Consumer parallelism is limited by the number of partitions. To increase consumer throughput, you must increase the number of partitions. The maximum consumers in a group equals the number of partitions. Beyond that, additional consumers are idle. The recommended maximum is 5000-10000 partitions per broker in KRaft mode.

For cross-datacenter replication, MirrorMaker 2 replicates topics between clusters. It uses Kafka Connect with mirror source and mirror sink connectors. The replication is asynchronous, so there is a replication lag proportional to the distance between data centers.

23. Production Incidents and Runbooks

Every production Kafka deployment encounters incidents. Here are common scenarios and their resolutions.

Under-Replicated Partitions

Symptom: UnderReplicatedPartitions metric is greater than 0. Causes: disk I/O bottleneck on a follower, network congestion, follower broker overloaded, or a follower that is down. Resolution: check disk I/O on follower brokers, check network bandwidth, verify broker health. Tune log.flush.interval.messages and log.flush.interval.ms if disks are slow. If a broker is down, bring it back or reassign its partitions to other brokers.

Disk Full

Symptom: broker stops accepting writes, produces errors. Resolution: increase retention period by reducing retention.ms or retention.bytes, add more storage to the broker, or trigger immediate segment deletion by reducing retention and waiting for cleanup. In extreme cases, partitions can be moved to brokers with more space using reassignment.

High Consumer Lag

Symptom: consumer lag grows unbounded. Causes: slow consumer processing, insufficient consumer parallelism, or a bottleneck in the processing pipeline. Resolution: increase the number of partitions and consumers, optimize processing logic, or add more resources to consumer instances. Check if the consumer is stuck on a particular message (poison pill).

Controller Flapping

Symptom: active controller changes rapidly. Causes: ZooKeeper session timeouts due to ZK latency or network issues. Resolution: upgrade to KRaft mode, which has faster failover and is not subject to ZK issues. If staying on ZK, check ZK cluster health, network between brokers and ZK, and increase ZK session timeout.

Request Latency Spikes

Symptom: produce or fetch latency increases suddenly. Causes: GC pauses, disk I/O spikes, network congestion, or CPU exhaustion. Resolution: check JVM GC logs for long pauses, check disk I/O with iostat, verify network with netstat. Add more brokers to distribute load, or tune heap size and GC settings.

IncidentCauseResolution
Under-replicated partitionsSlow/failed followerCheck disk, network, add brokers
Disk fullRetention too longReduce retention, add storage
High consumer lagSlow processingIncrease partitions, optimize code
Controller flappingZK session timeoutMigrate to KRaft
Latency spikesGC or I/O bottleneckTune heap, add brokers
Unbalanced loadUneven partition distributionReassign partitions

24. Cost Estimation and Hardware Sizing

Understanding the cost of running a Kafka cluster is essential for budget planning and architecture reviews. We break down costs into hardware, networking, and operational overhead.

Hardware Costs

For a mid-sized cluster of 20 brokers with 50 TB NVMe storage and 128 GB RAM each, the hardware cost is substantial. Each broker costs approximately $15000-25000 depending on configuration. Twenty brokers cost $300000-500000. Storage is the dominant cost. NVMe drives cost about $0.20/GB, so 50 TB per broker costs $10000 per broker, or $200000 total. Memory at $10/GB for 128 GB adds $1280 per broker. CPU at $5000 per high-end Xeon processor adds $10000 per broker for dual sockets.

Cloud Costs

Cloud costs depend on the provider and instance type. Using AWS MSK (Managed Streaming for Kafka), a cluster of 20 kafka.m5.24xlarge instances costs approximately $30-40 per hour, or $260000-350000 per year. Self-managed on EC2 using i3en.24xlarge instances (8 x 7.5 TB NVMe SSD, 768 GB RAM) costs about $15-20 per hour for compute plus storage costs. EBS gp3 volumes add $0.08/GB/month for the storage component.

ComponentOn-Prem (Annual)AWS MSK (Annual)Self-Managed EC2 (Annual)
20 brokers$240000$300000$140000
Storage (1PB)$200000Included$96000
Network$50000IncludedIncluded
Operations$150000$30000$100000
Total$640000$330000$336000

CPU and Memory Sizing

CPU requirements depend on throughput, compression algorithm, and whether the broker handles producer compression or leaves it to clients. A rule of thumb: 1 core per 100 MB/s of throughput for uncompressed data, and 1 core per 50 MB/s for compressed data (decompression on consumer fetches). Memory sizing: allocate 50 percent of available RAM to the JVM heap (max 32-64 GB recommended for the JVM to keep GC manageable) and leave the rest for the OS page cache.

For a cluster handling 1 GB/s ingress with replication factor 3 (3 GB/s total writes) and 2 GB/s consumer reads, the total throughput is 5 GB/s. At 100 MB/s per core, you need about 50 cores across the cluster. With 20 brokers, each needs 2-3 cores minimum, but modern deployments use 16-32 core processors to handle peaks, GC overhead, and background tasks like compaction.

Cost-Saving Tip: Use tiered storage to move data older than a few days to S3. This reduces local storage requirements by 70-90 percent, significantly lowering hardware costs. Consider using KRaft mode to eliminate ZooKeeper nodes, saving 3-5 additional server instances.

25. Edge Cases and Failure Modes

Understanding edge cases is what separates a senior engineer from a junior one. Here are the critical edge cases in Kafka systems.

Leader Failure During Write

If the partition leader fails while a producer has unacknowledged writes (acks=1), those writes may be lost. The new leader is elected from the ISR, but it may not have the latest data. The producer will receive a leader-not-available error and should retry. With acks=all and min.insync.replicas=2, at least one follower has the data, so no loss occurs. With idempotent producer, retries do not create duplicates.

Split Brain

In ZooKeeper mode, a split-brain scenario can occur if the controller loses connection to ZK but continues operating. ZK fencing prevents this: a broker cannot act as controller if it cannot maintain the ZK session. In KRaft mode, Raft consensus prevents split-brain because a controller must have a majority of quorum votes to lead. This is a fundamental advantage of KRaft over ZK mode.

Message Too Large

If a message exceeds message.max.bytes, the producer receives an error. This must be configured consistently across topic (message.max.bytes), broker (message.max.bytes), and consumer (max.partition.fetch.bytes). Large messages (over 10MB) are better handled by storing the payload in an external blob store and storing the reference in Kafka.

Consumer Poison Pill

A message that causes the consumer to fail repeatedly (poison pill) can block progress. The consumer keeps retrying, and the offset is never committed, causing the group to stall. Solutions include a dead letter queue (DLQ) topic for failed messages, configurable retry limits, and skip-on-error logic with alerting.

Rebalance Storm

In large clusters with many consumer groups and partitions, simultaneous rebalances can cause a storm. When one consumer fails, its partitions rebalance. The rebalance itself can cause other consumers to be removed (session timeout), triggering cascading rebalances. Solutions include using CooperativeStickyAssignor, increasing session.timeout.ms, and ensuring heartbeat intervals are appropriate.

Log Cleaner Stuck

The log cleaner can get stuck if segments are too large, there are too many segments, or disk I/O is saturated. Monitor LogCleanerManager metrics for time spent cleaning. If stuck, increase the number of cleaner threads (log.cleaner.threads) or reduce segment size.

Disk Full on Controller

If the controller broker runs out of disk, metadata operations fail. Topic creation, partition reassignment, and leader elections stop working. The cluster continues to serve existing data, but no administrative operations succeed. Recovery requires freeing disk space or replacing the broker.

Edge CaseImpactMitigation
Leader failure during writeData loss (acks=1)Use acks=all, min.insync.replicas=2
Split brainMetadata corruptionUse KRaft mode
Message too largeProducer errorExternal blob store for large payloads
Poison pillConsumer group stallsDLQ topic, retry limits
Rebalance stormProcessing disruptionCooperativeStickyAssignor
Log cleaner stuckDisk fills upIncrease cleaner threads

26. Interview Q&A � 50+ Questions

This section covers the most common Kafka system design interview questions with comprehensive answers. These questions are frequently asked at FAANG companies and Tier-1 startups for senior engineering positions.

Core Architecture Questions

Q1: How does Kafka achieve high throughput? Kafka achieves high throughput through several mechanisms working together. Sequential append-only writes eliminate random I/O, allowing Kafka to saturate modern NVMe drives at 3-6 GB/s. The OS page cache serves recently written and read data without JVM heap involvement, avoiding GC overhead. Zero-copy sendfile() transfers data directly from the page cache to the network socket, reducing context switches and memory copies. Producer-side batching groups records into batches of up to 1MB, reducing per-record overhead from 22 bytes to under 1 byte. Compression (lz4, zstd, snappy) reduces network and storage bandwidth by 3-4x. Consumer long-polling with configurable fetch.min.bytes ensures efficient batch fetches.

Q2: How is Kafka different from RabbitMQ? Kafka is pull-based consumers poll for data, while RabbitMQ is push-based brokers push data to consumers. Kafka persists messages to disk and retains them based on configurable policies (time or size), enabling replay, while RabbitMQ deletes messages after consumption. Kafka provides strong ordering guarantees within a partition, while RabbitMQ ordering depends on the exchange/binding topology. Kafka is designed for high-throughput, while RabbitMQ excels at low-latency routing and complex messaging patterns. Kafka uses a distributed commit log model with partitions and consumer groups for parallelism, while RabbitMQ uses queues and exchanges with competing consumers.

Q3: What is the role of ZooKeeper in Kafka? ZooKeeper manages cluster metadata: broker registry (which brokers are alive), topic configuration (partitions, replication factor, retention policy), partition leader assignments (which broker is the leader for each partition), ISR management (tracking which replicas are in-sync), and controller election. In KRaft mode, ZooKeeper is replaced by an internal Raft-based quorum of controllers.

Q4: How does a consumer group work? A consumer group is a set of consumers that coordinate to consume from one or more topics. Each partition is assigned to exactly one consumer in the group, ensuring ordered processing within each partition. The group coordinator (a broker) manages group membership, partition assignment, and offset tracking. Consumers join, send heartbeats, and leave the group through a protocol coordinated by the group coordinator.

Q5: What happens when a broker fails? When a broker fails, the controller detects the failure through ZooKeeper session expiry or KRaft heartbeat timeout. The controller identifies all partitions whose leader was on the failed broker and elects new leaders from the ISR for each partition. The controller updates the metadata with the new leader assignments, and clients discover the new leaders through metadata refresh.

Q6: What is the difference between a topic and a partition? A topic is a logical category or feed name to which records are published. A partition is an ordered, immutable sequence of records within a topic. A topic consists of one or more partitions, each stored on potentially different brokers. Partitions are the unit of parallelism for consumption and the unit of storage on disk.

Q7: How does Kafka handle rebalancing? Rebalancing redistributes partition assignments among consumers in a group. It is triggered when a consumer joins, leaves, or fails. The group coordinator receives a JoinGroup request, selects a group leader, sends the member list to the leader, the leader creates a new partition assignment, and the assignment is distributed to all consumers through the SyncGroup protocol.