system-design24 min read

Design a Distributed Message Queue - The System Design Codex

Design a Distributed Message Queue

A deep-dive into message queue architectures, delivery semantics, event streaming, and production-grade C# implementations.

Last updated: July 2025 | Reading time: ~35 minutes | Words: 11,000+

1. Introduction & Motivation

A distributed message queue is infrastructure that enables asynchronous communication between services. Producers send messages to the queue, and consumers read them at their own pace. The queue decouples producers from consumers, absorbs traffic spikes, and provides fault tolerance — if a consumer crashes, messages wait in the queue until it recovers.

Every large-scale system relies on message queues. Uber uses Kafka to process 7 million events per second for ride matching and ETA calculations. LinkedIn processes 2 trillion messages per day through Kafka for activity tracking, metrics, and log aggregation. Netflix uses SQS and Kafka for video encoding pipelines, recommendation updates, and billing events. Without message queues, these systems would be tightly coupled, fragile, and unable to scale independently.

The two fundamental paradigms are message queuing (point-to-point, competing consumers, each message processed once) and pub/sub (one-to-many, each subscriber gets every message). Modern systems like Kafka blur this line by combining durable log storage with pub/sub semantics. This article covers both paradigms, their trade-offs, and production-grade implementations in C#.

Why Message Queues Matter

  1. Decoupling: Services can evolve independently without breaking each other.
  2. Buffering: Absorb traffic spikes — consumers process at their own pace.
  3. Resilience: If a consumer crashes, messages survive in the queue.
  4. Scalability: Add more consumers to increase throughput without changing producers.
  5. Async processing: Non-blocking operations — fire and forget.

2. Interview Context

Why Interviewers Ask This

  • Async architecture: Do you understand when to use sync vs async communication?
  • Delivery semantics: Can you explain at-least-once vs exactly-once vs at-most-once?
  • Trade-off analysis: Ordering vs throughput, durability vs latency, simplicity vs features.
  • Scalability: How do you partition messages across brokers? How do consumers scale?
  • Failure handling: What happens when a broker dies? When a consumer crashes mid-processing?

Common Framing

Interviewers may frame this as "Design a notification system," "Design an event-driven architecture," or "How would you decouple Service A from Service B?" The core answer is always a message queue — the specific design depends on the requirements (ordering, durability, throughput).

3. Functional Requirements

  • Publish messages: Producers send messages to a named topic/queue.
  • Subscribe to messages: Consumers receive messages from topics they subscribe to.
  • Point-to-point: Each message is processed by exactly one consumer (competing consumers).
  • Pub/sub: Each message is delivered to all subscribers.
  • Message ordering: Messages within a partition are delivered in order.
  • Message retention: Messages are retained for configurable duration (hours to days).
  • Dead letter queue: Messages that fail processing are moved to a DLQ for investigation.
  • Consumer groups: Multiple consumers share the load within a group.

4. Non-Functional Requirements

RequirementTargetJustification
Throughput100K+ messages/secMust handle high-volume event streams
Latency (publish)< 10ms p99Low overhead for producers
Latency (deliver)< 50ms p99Near-real-time consumer delivery
DurabilityMessages survive broker crashNo message loss for critical events
Availability99.99%Message queue is critical infrastructure
OrderingPer-partition strict orderingEvent ordering matters for state machines
Retention7 days configurableReplay capability for debugging/reprocessing
Max message size10 MBSupport event payloads with attachments

5. Requirement Prioritization

PriorityRequirement
MustAt-least-once delivery, durable storage, consumer groups, topic-based routing
MustPartitioned ordering, dead letter queue, message retention
ShouldExactly-once semantics (idempotent producers), consumer lag monitoring, replay
ShouldCross-datacenter replication, schema registry, message compression
CouldTransactional outbox, event sourcing, stream processing integration

6. Capacity Estimation

MetricValueCalculation
Messages/sec (peak)100,000Given
Average message size1 KBMeasured from production
Daily throughput8.6 TB100K × 1KB × 86400s
Retention period7 daysConfigurable per topic
Total storage needed60 TB8.6TB × 7 days
Replication factor3Standard for HA
Total storage with replication180 TB60TB × 3
Number of brokers306TB per broker (SSD)

Hardware Sizing

ComponentSpec per NodeCount
Broker (storage)16 vCPU, 64GB RAM, 6TB NVMe SSD30
Broker (compute-heavy)32 vCPU, 128GB RAM, 2TB SSD10
ZooKeeper / etcd (metadata)4 vCPU, 8GB RAM, 200GB SSD5
'@ Add-Content -Path "D:\10blogs\distributed-message-queue.html" -Value $a -Encoding UTF8

7. Message Models

Point-to-Point (Queue)

One producer, one consumer per message. Multiple consumers compete for messages in the queue. Each message is processed exactly once. Ideal for task distribution and work queues.

Point-to-Point Queue

graph LR P[Producer] --> Q[Queue] Q --> C1[Consumer 1] Q --> C2[Consumer 2] Q --> C3[Consumer 3] M1[Message 1] --> C1 M2[Message 2] --> C2 M3[Message 3] --> C3 style Q fill:#0099ff

Publish/Subscribe (Topic)

One producer, many subscribers. Each subscriber receives every message. Ideal for event broadcasting, notifications, and fan-out patterns.

Publish/Subscribe

graph LR P[Producer] --> T[Topic] T --> S1[Subscriber 1] T --> S2[Subscriber 2] T --> S3[Subscriber 3] M[Message] -.-> S1 M -.-> S2 M -.-> S3 style T fill:#ff6b35

Event Streaming (Log-based)

Messages are stored in an append-only log. Consumers read from their position in the log. Messages are retained for configurable duration. Ideal for event sourcing, replay, and stream processing.

Event Log with Consumer Groups

graph TB P[Producer] --> L[Append-Only Log] L --> P0["Partition 0
[m1, m2, m3, m4, m5]"] L --> P1["Partition 1
[m6, m7, m8, m9]"] L --> P2["Partition 2
[m10, m11, m12]"] P0 --> CG1["Consumer Group A"] P1 --> CG1 P2 --> CG1 P0 --> CG2["Consumer Group B"] P1 --> CG2 P2 --> CG2 CG1 --> CA1[Consumer A1] CG1 --> CA2[Consumer A2] CG2 --> CB1[Consumer B1] style L fill:#4caf50

Model Comparison

FeaturePoint-to-PointPub/SubEvent Streaming
DeliveryOnce per messageOnce per subscriberOnce per consumer group
Message retentionUntil consumedUntil consumedConfigurable (hours/days)
OrderingFIFO per queueNo guarantee across subscribersPer-partition ordering
ReplayNot supportedNot supportedFull replay from any offset
Best forTask queues, job distributionNotifications, broadcastsEvent sourcing, analytics
ExamplesRabbitMQ, AWS SQSRabbitMQ (fanout), Google Pub/SubApache Kafka, AWS Kinesis

8. Delivery Guarantees

Delivery semantics define the contract between the queue and its clients. The choice of guarantee directly impacts system complexity, throughput, and latency.

At-Most-Once

Messages may be lost but are never delivered twice. The producer sends and forgets. If the broker crashes before persisting, the message is lost. Simple but risky for critical data.

// At-most-once: Fire and forget
public class AtMostOnceProducer
{
    public async Task SendAsync(string topic, byte[] payload)
    {
        // Send without waiting for ack
        _ = _channel.WriteAsync(new Message { Topic = topic, Payload = payload });
        // No retry, no confirmation
    }
}

At-Least-Once (Recommended Default)

Messages are never lost but may be delivered more than once. The broker persists the message before acking. If the consumer crashes mid-processing, the message is redelivered. Consumers must be idempotent.

// At-least-once: Ack after processing
public class AtLeastOnceConsumer
{
    public async Task ProcessMessagesAsync(string topic, string groupId)
    {
        while (true)
        {
            var message = await _consumer.PollAsync(topic, groupId);

            try
            {
                await ProcessMessage(message);

                // Ack only AFTER successful processing
                await _consumer.AcknowledgeAsync(message.Offset);
            }
            catch (Exception ex)
            {
                // Don't ack — message will be redelivered
                _logger.LogError(ex, "Failed to process message {Offset}", message.Offset);
            }
        }
    }
}

Exactly-Once

Each message is delivered exactly once. The hardest to achieve — requires coordination between producer, broker, and consumer. Typically implemented via idempotent producers + transactional offsets.

// Exactly-once: Idempotent producer + transactional consume-produce
public class ExactlyOnceProcessor
{
    public async Task ProcessExactlyOnce(string inputTopic, string outputTopic)
    {
        var transaction = await _kafkaClient.BeginTransactionAsync();
        try
        {
            // Consume within transaction
            var message = await _consumer.ConsumeAsync(inputTopic, transaction);

            // Process (idempotent operation)
            var result = await IdempotentProcess(message);

            // Produce output within same transaction
            await _producer.ProduceAsync(outputTopic, result, transaction);

            // Commit consumer offset within transaction
            await _consumer.CommitOffsetAsync(message.Offset, transaction);

            // Commit everything atomically
            await transaction.CommitAsync();
        }
        catch
        {
            await transaction.AbortAsync();
        }
    }
}

Delivery Guarantee Comparison

GuaranteeMessage LossDuplicationComplexityThroughputUse When
At-most-oncePossibleNeverLowHighestMetrics, logs, non-critical events
At-least-onceNeverPossibleMediumHighMost use cases (recommended default)
Exactly-onceNeverNeverHighLowerFinancial transactions, billing

Idempotency is the Key

With at-least-once delivery (the practical default), messages may be delivered twice during failures. The solution is idempotent consumers — operations that produce the same result whether executed once or twice. Common patterns: database upserts (INSERT ON CONFLICT), deduplication tables, and idempotency keys.

9. High-Level Architecture

Distributed Message Queue Architecture

graph TB subgraph "Producer Layer" P1[Producer A] --> LB[Load Balancer] P2[Producer B] --> LB P3[Producer C] --> LB end subgraph "Broker Cluster" LB --> B1[Broker 1
Partition 0,1] LB --> B2[Broker 2
Partition 2,3] LB --> B3[Broker 3
Partition 4,5] B1 <-->|"Replication"| B2 B2 <-->|"Replication"| B3 end subgraph "Metadata Service" B1 --> ZK[(ZooKeeper/etcd)] B2 --> ZK B3 --> ZK end subgraph "Consumer Layer" B1 --> CG1[Consumer Group 1] B2 --> CG1 B3 --> CG1 B1 --> CG2[Consumer Group 2] B2 --> CG2 CG1 --> C1[Consumer 1.1] CG1 --> C2[Consumer 1.2] CG2 --> C3[Consumer 2.1] end style B1 fill:#ff6b35 style B2 fill:#ff6b35 style B3 fill:#ff6b35 style ZK fill:#0099ff

10. Production Architecture Diagram

Message Publish Flow

Producing a Message

sequenceDiagram participant P as Producer participant LB as Load Balancer participant B as Broker (Leader) participant R1 as Replica 1 participant R2 as Replica 2 P->>LB: Publish(topic, key, value) LB->>B: Route to partition leader B->>B: Append to commit log B->>R1: Replicate B->>R2: Replicate R1-->>B: ACK R2-->>B: ACK B-->>P: offset + ACK

Message Consume Flow

Consuming with Consumer Groups

sequenceDiagram participant C1 as Consumer 1 (Group A) participant C2 as Consumer 2 (Group A) participant B1 as Broker (Partition 0) participant B2 as Broker (Partition 1) Note over C1,B2: Consumer Group A has 2 consumers, 2 partitions C1->>B1: Poll(Partition 0, offset=100) B1-->>C1: Message(offset=100, data) C2->>B2: Poll(Partition 1, offset=50) B2-->>C2: Message(offset=50, data) C1->>C1: Process message C1->>B1: Commit offset=101 C2->>C2: Process message C2->>B2: Commit offset=51

11. Component Deep Dive

Append-Only Log

The core data structure is an append-only log. Messages are appended in order, assigned monotonically increasing offsets, and stored on disk with sequential I/O for maximum throughput.

public class AppendOnlyLog
{
    private readonly string _logDirectory;
    private readonly long _maxSegmentSize;
    private LogSegment _activeSegment;

    public AppendOnlyLog(string directory, long maxSegmentSizeMB = 1024)
    {
        _logDirectory = directory;
        _maxSegmentSize = maxSegmentSizeMB * 1024 * 1024;
        Directory.CreateDirectory(directory);
        LoadSegments();
    }

    public async Task<long> AppendAsync(byte[] data, Dictionary<string, byte[]>? headers = null)
    {
        var entry = new LogEntry
        {
            Offset = _nextOffset++,
            Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
            Headers = headers ?? new Dictionary<string, byte[]>(),
            Payload = data,
            Checksum = Crc32.Compute(data)
        };

        var bytes = Serialize(entry);
        await _activeSegment.AppendAsync(bytes);

        if (_activeSegment.Size >= _maxSegmentSize)
            await RollSegmentAsync();

        return entry.Offset;
    }

    public async IAsyncEnumerable<LogEntry> ReadAsync(
        long startOffset,
        int maxMessages = 1000)
    {
        foreach (var segment in _segments.Where(s => s.EndOffset >= startOffset))
        {
            await foreach (var entry in segment.ReadAsync(startOffset))
            {
                yield return entry;
                if (--maxMessages <= 0) yield break;
            }
        }
    }
}

public class LogSegment
{
    private readonly FileStream _file;
    private readonly Dictionary<long, long> _offsetIndex; // offset -> file position
    public long StartOffset { get; }
    public long EndOffset { get; private set; }
    public long Size => _file.Length;

    public async Task AppendAsync(byte[] serializedEntry)
    {
        var position = _file.Position;
        _offsetIndex[EndOffset] = position;
        await _file.WriteAsync(serializedEntry);
        EndOffset++;
    }

    public async IAsyncEnumerable<LogEntry> ReadAsync(long startOffset)
    {
        if (!_offsetIndex.TryGetValue(startOffset, out var position))
        {
            // Find nearest offset
            position = _offsetIndex.Where(kv => kv.Key <= startOffset)
                .OrderByDescending(kv => kv.Key)
                .FirstOrDefault().Value;
        }

        _file.Seek(position, SeekOrigin.Begin);
        using var reader = new BinaryReader(_file, Encoding.UTF8, leaveOpen: true);

        while (_file.Position < _file.Length)
        {
            var entry = Deserialize(reader);
            if (entry.Offset >= startOffset)
                yield return entry;
        }
    }
}

Broker with Replication

public class Broker
{
    private readonly string _brokerId;
    private readonly Dictionary<string, Partition> _partitions;
    private readonly IReplicationManager _replication;
    private readonly ILogger<Broker> _logger;

    public async Task<PublishResult> PublishAsync(string topic, string key, byte[] value)
    {
        var partition = _partitions[topic];
        var partitionIndex = GetPartitionIndex(key, partition.ReplicaCount);

        // Only leader accepts writes
        if (!partition.IsLeader(partitionIndex))
            throw new NotLeaderException(partition.GetLeader(partitionIndex));

        // Append to local log
        var offset = await partition.AppendAsync(partitionIndex, value);

        // Replicate to followers
        var replicas = partition.GetReplicas(partitionIndex);
        var acks = 0;
        var requiredAcks = (replicas.Count / 2) + 1; // Quorum

        var replicationTasks = replicas.Select(async replica =>
        {
            try
            {
                await _replication.ReplicateAsync(replica, topic, partitionIndex, offset, value);
                Interlocked.Increment(ref acks);
            }
            catch (Exception ex)
            {
                _logger.LogWarning("Replication failed to {Replica}: {Error}", replica, ex.Message);
            }
        });

        await Task.WhenAll(replicationTasks);

        if (acks >= requiredAcks)
        {
            return new PublishResult
            {
                Success = true,
                Offset = offset,
                Partition = partitionIndex
            };
        }

        throw new InsufficientReplicasException(
            $"Only {acks}/{requiredAcks} replicas acknowledged");
    }
}

public class Partition
{
    public string Topic { get; set; }
    public int PartitionIndex { get; set; }
    public string LeaderBroker { get; set; }
    public List<string> Replicas { get; set; }
    public AppendOnlyLog Log { get; set; }

    public bool IsLeader(int partitionIndex) =>
        LeaderBroker == Environment.MachineName;

    public string GetLeader(int partitionIndex) => LeaderBroker;
}

Consumer Group Coordinator

public class ConsumerGroupCoordinator
{
    private readonly Dictionary<string, ConsumerGroup> _groups = new();
    private readonly ITopicMetadata _metadata;

    public PartitionAssignment AssignPartitions(string groupId, string topic)
    {
        var group = _groups.GetOrAdd(groupId, _ => new ConsumerGroup
        {
            GroupId = groupId,
            Members = new List<string>(),
            State = GroupState.Stable
        });

        var partitions = _metadata.GetPartitions(topic);
        var members = group.Members;

        // Round-robin assignment
        var assignments = new Dictionary<string, List<int>>();
        for (int i = 0; i < partitions.Count; i++)
        {
            var memberId = members[i % members.Count];
            if (!assignments.ContainsKey(memberId))
                assignments[memberId] = new List<int>();
            assignments[memberId].Add(i);
        }

        return new PartitionAssignment { Assignments = assignments };
    }

    public async Task HeartbeatAsync(string groupId, string memberId)
    {
        if (_groups.TryGetValue(groupId, out var group))
        {
            var member = group.Members.FirstOrDefault(m => m == memberId);
            if (member != null)
                group.LastHeartbeat[memberId] = DateTimeOffset.UtcNow;
        }
    }

    public void CheckMemberHealth(TimeSpan timeout)
    {
        var now = DateTimeOffset.UtcNow;
        foreach (var group in _groups.Values)
        {
            var deadMembers = group.LastHeartbeat
                .Where(kv => now - kv.Value > timeout)
                .Select(kv => kv.Key)
                .ToList();

            foreach (var memberId in deadMembers)
            {
                group.Members.Remove(memberId);
                _logger.LogWarning("Member {MemberId} timed out in group {GroupId}",
                    memberId, group.GroupId);
                // Trigger rebalance
                RebalanceGroup(group.GroupId);
            }
        }
    }
}

12. Data Modeling

Topic & Partition Metadata

CREATE TABLE topics (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) UNIQUE NOT NULL,
    partition_count INT NOT NULL DEFAULT 6,
    replication_factor INT NOT NULL DEFAULT 3,
    retention_ms BIGINT NOT NULL DEFAULT 604800000,
    config JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE partitions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    topic_id UUID REFERENCES topics(id),
    partition_index INT NOT NULL,
    leader_broker_id UUID NOT NULL,
    replica_broker_ids UUID[] NOT NULL,
    start_offset BIGINT DEFAULT 0,
    end_offset BIGINT DEFAULT 0,
    UNIQUE(topic_id, partition_index)
);

CREATE TABLE consumer_offsets (
    group_id VARCHAR(255) NOT NULL,
    topic VARCHAR(255) NOT NULL,
    partition_index INT NOT NULL,
    committed_offset BIGINT NOT NULL,
    last_updated TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (group_id, topic, partition_index)
);

CREATE INDEX idx_partitions_topic ON partitions(topic_id);
CREATE INDEX idx_offsets_group ON consumer_offsets(group_id);

Internal Data Structures

StructurePurposeStorage
Commit LogDurable message storageAppend-only files on SSD
Offset IndexMap offset to file positionIn-memory + sparse index on disk
Time IndexMap timestamp to offsetIn-memory + sparse index on disk
Consumer OffsetTrack consumer group progressInternal __consumer_offsets topic
Group MembershipTrack active consumers per groupCoordinator in-memory + ZooKeeper

13. API Design

Producer API

public interface IMessageProducer
{
    Task<PublishResult> PublishAsync<T>(string topic, string key, T value,
        Dictionary<string, byte[]>? headers = null);
    Task<List<PublishResult>> BatchPublishAsync<T>(string topic,
        IEnumerable<MessageEnvelope<T>> messages);
    event Action<PublishError>? OnPublishError;
}

public class PublishResult
{
    public bool Success { get; set; }
    public long Offset { get; set; }
    public int Partition { get; set; }
    public string? Error { get; set; }
}

Consumer API

public interface IMessageConsumer : IDisposable
{
    Task<ConsumeResult<T>> ConsumeAsync<T>(string topic, string groupId,
        CancellationToken cancellationToken = default);
    Task<IAsyncEnumerable<ConsumeResult<T>>> ConsumeStreamAsync<T>(
        string topic, string groupId, CancellationToken cancellationToken = default);
    Task AcknowledgeAsync(long offset);
    Task<ConsumerLag> GetLagAsync(string topic, string groupId);
}

public class ConsumeResult<T>
{
    public string Topic { get; set; }
    public int Partition { get; set; }
    public long Offset { get; set; }
    public string Key { get; set; }
    public T Value { get; set; }
    public Dictionary<string, byte[]> Headers { get; set; }
    public DateTimeOffset Timestamp { get; set; }
}

Admin API

public interface IMessageQueueAdmin
{
    Task<TopicMetadata> CreateTopicAsync(string name, int partitions,
        int replicationFactor, TimeSpan retention);
    Task DeleteTopicAsync(string name);
    Task<List<TopicMetadata>> ListTopicsAsync();
    Task ResetConsumerOffsetAsync(string groupId, string topic,
        int partition, long offset);
}

ASP.NET Core Integration

builder.Services.AddSingleton<IMessageProducer, KafkaProducer>(sp =>
{
    var config = builder.Configuration.GetSection("Kafka");
    return new KafkaProducer(new ProducerConfig
    {
        BootstrapServers = config["BootstrapServers"],
        Acks = Acks.All,
        EnableIdempotence = true,
        LingerMs = 5,
        BatchSize = 16384,
        CompressionType = CompressionType.Lz4
    });
});

public class OrderEventConsumer : BackgroundService
{
    private readonly IMessageConsumer _consumer;
    private readonly IServiceProvider _services;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var result in _consumer.ConsumeStreamAsync<OrderEvent>(
            "orders", "order-processor-group", stoppingToken))
        {
            try
            {
                using var scope = _services.CreateScope();
                var handler = scope.ServiceProvider.GetRequiredService<IOrderEventHandler>();
                await handler.HandleAsync(result.Value);
                await _consumer.AcknowledgeAsync(result.Offset);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to process order event at offset {Offset}", result.Offset);
            }
        }
    }
}

14. Storage Engine

Log Segment Files

Messages are stored in segmented log files on disk. Each segment has a base offset and a maximum size. Old segments are deleted based on retention policy. The active segment receives new writes.

public class LogStorageEngine
{
    private readonly string _topicDir;
    private readonly ConcurrentDictionary<int, PartitionLog> _partitionLogs = new();
    private readonly TimeSpan _retentionPeriod;

    public async Task<long> AppendAsync(int partition, byte[] data)
    {
        var log = _partitionLogs.GetOrAdd(partition,
            _ => new PartitionLog(Path.Combine(_topicDir, $"partition-{partition}")));
        return await log.AppendAsync(data);
    }

    public async IAsyncEnumerable<MessageEntry> ReadAsync(
        int partition, long startOffset, int maxCount = 1000)
    {
        if (!_partitionLogs.TryGetValue(partition, out var log))
            yield break;
        await foreach (var entry in log.ReadAsync(startOffset, maxCount))
            yield return entry;
    }
}

15. Read/Write Path

Write Path Optimizations

  • Sequential I/O: Append-only writes use sequential disk I/O, which is 100-1000x faster than random I/O.
  • Batch writes: Multiple messages batched into a single disk write (linger.ms, batch.size).
  • Zero-copy: Messages sent directly from disk to network socket without user-space copy.
  • Page cache: OS page cache buffers recent writes; fsync configurable for durability vs performance.

Read Path Optimizations

  • Sequential reads: Consumers read sequentially from their current offset.
  • Zero-copy transfer: sendfile() syscall transfers data directly from page cache to network.
  • Prefetching: Broker prefetches next batch while consumer processes current batch.
  • Compression: Messages compressed in batches (LZ4, Snappy, Zstd) — reduces I/O and network.

16. Consumer Patterns

Push vs Pull

ModelMechanismProsCons
Push (RabbitMQ)Broker delivers to consumerLow latency, real-timeConsumer overload risk, backpressure needed
Pull (Kafka)Consumer polls brokerConsumer controls pace, simple backpressureHigher latency if poll interval is long

Consumer Group Rebalancing

Rebalance When Consumer Joins/Leaves

sequenceDiagram participant CG as Coordinator participant C1 as Consumer 1 participant C2 as Consumer 2 participant C3 as Consumer 3 (joining) Note over CG: Consumer 3 joins group CG->>C1: Rebalance triggered CG->>C2: Rebalance triggered CG->>C3: Rebalance triggered Note over CG: Reassign partitions CG->>C1: New assignment: [Partition 0] CG->>C2: New assignment: [Partition 2] CG->>C3: New assignment: [Partition 1]

17. Ordering & Partitioning

Partitioning Strategy

public class Partitioner
{
    public int GetPartition(string key, int partitionCount)
    {
        return Math.Abs(key.GetHashCode()) % partitionCount;
    }
}

Ordering Guarantees

ScopeGuaranteeMechanism
Per partitionStrict orderingAppend-only log with sequential offsets
Per keyOrdering per keyHash-based partitioning: same key to same partition
GlobalNo guarantee (by default)Would require single partition = throughput bottleneck
Interview Trap: If the interviewer asks for global ordering, explain the trade-off: a single partition guarantees global ordering but limits throughput to a single broker's capacity. For most use cases, per-key ordering is sufficient and scales horizontally.

18. Scalability

Horizontal Scaling

Add more brokers and partitions to increase throughput. Partition count determines maximum parallelism within a consumer group.

Partition CountMax ThroughputMax Consumers/Group
6~300K msg/s6
24~1.2M msg/s24
100+~5M msg/s100+

19. Distributed Systems Design

In-Sync Replicas (ISR)

Only replicas that are sufficiently caught up with the leader are considered "in sync." The ISR ensures new leaders have the most recent data.

public class ISRManager
{
    private readonly Dictionary<string, HashSet<string>> _isr = new();

    public async Task CheckReplicaLagAsync(string topic, int partition)
    {
        var leaderOffset = await GetLeaderOffset(topic, partition);
        var replicas = await GetReplicas(topic, partition);

        foreach (var replica in replicas)
        {
            var replicaOffset = await GetReplicaOffset(replica, topic, partition);
            var lag = leaderOffset - replicaOffset;

            if (lag > 10000) // Max lag threshold
                _isr[Key(topic, partition)].Remove(replica);
            else
                _isr[Key(topic, partition)].Add(replica);
        }
    }
}

20. Consistency Models

ScenarioGuaranteeMechanism
Message durabilityACK after disk flushfsync to disk before acking producer
Consumer offsetCommit after processingStore offset in internal topic
ReplicationQuorum writeWrite to ISR before acking
OrderingPer-partition strictAppend-only log with sequential offsets

21. Reliability

Dead Letter Queue

public class DeadLetterQueue
{
    private readonly IMessageProducer _producer;
    private readonly int _maxRetries = 3;

    public async Task<bool> ProcessWithDLQAsync<T>(
        string topic, string groupId, Func<T, Task<bool>> processor)
    {
        var result = await _consumer.ConsumeAsync<MessageWithRetry<T>>(topic, groupId);

        if (result.Value.RetryCount >= _maxRetries)
        {
            await _producer.PublishAsync($"{topic}.dlq", result.Key,
                result.Value.Payload, new Dictionary<string, byte[]>
                {
                    ["retry-count"] = BitConverter.GetBytes(result.Value.RetryCount),
                    ["error"] = Encoding.UTF8.GetBytes(result.Value.LastError ?? "unknown")
                });
            await _consumer.AcknowledgeAsync(result.Offset);
            return false;
        }

        try
        {
            var success = await processor(result.Value.Payload);
            if (success) { await _consumer.AcknowledgeAsync(result.Offset); return true; }
        }
        catch (Exception ex) { result.Value.LastError = ex.Message; }

        result.Value.RetryCount++;
        await _producer.PublishAsync(topic, result.Key, result.Value);
        await _consumer.AcknowledgeAsync(result.Offset);
        return false;
    }
}

22. Security

  • Authentication: SASL/SCRAM for client auth, mTLS for inter-broker communication.
  • Authorization: ACLs per topic — control produce/consume permissions.
  • Encryption in transit: TLS for all client-broker and broker-broker communication.
  • Encryption at rest: Disk-level encryption (LUKS, BitLocker) for message storage.
  • Audit logging: Log all topic create/delete, ACL changes, and client connections.

23. Observability

Key Metrics

MetricTypeDescriptionAlert
mq_messages_published_totalCounterTotal messages published-
mq_messages_consumed_totalCounterTotal messages consumed-
mq_consumer_lag_messagesGaugeConsumer lag in messagesLag > 10K
mq_consumer_lag_msGaugeConsumer lag in millisecondsLag > 60s
mq_publish_latency_msHistogramPublish round-trip timep99 > 50ms
mq_isr_shrink_rateCounterISR shrink eventsAny shrink
mq_disk_usage_bytesGaugeDisk usage per broker> 80%

24. High Availability

Multi-Broker Replication

Each partition has one leader and N-1 followers. If the leader dies, a follower in the ISR is promoted. Replication factor of 3 is standard.

Multi-Datacenter Replication

Cross-Datacenter Mirror

graph TB subgraph "DC1 (Primary)" P1[Producer] --> B1[Broker Cluster DC1] end subgraph "DC2 (Secondary)" P2[Producer] --> B2[Broker Cluster DC2] end B1 -->|"MirrorMaker
async replication"| B2 B2 -->|"MirrorMaker"| B1 B1 --> C1[Consumers DC1] B2 --> C2[Consumers DC2]

25. Performance

Latency Breakdown

ComponentP50P99Optimization
Producer to Broker (network)0.5ms3msBatching, compression
Broker write (disk)0.1ms1msSequential I/O, page cache
Replication1ms5msParallel replication
Broker to Consumer0.5ms3msZero-copy, prefetch
Total end-to-end2ms12ms

Throughput Optimizations

  • Batching: Group messages into batches. Amortizes network and disk overhead.
  • Compression: LZ4/Snappy/Zstd. 2-5x reduction in I/O at ~5% CPU cost.
  • Zero-copy: sendfile() — data from page cache to network without user-space copy.
  • Sequential I/O: Append-only writes are 100x faster than random writes.

26. Cost Analysis

ComponentSpecCountMonthly Cost
Message brokersc5.2xlarge (8 vCPU, 6TB NVMe)30$10,500
ZooKeeperc5.large (2 vCPU)5$500
Network + monitoringCross-AZ traffic-$500
Total$11,500/month

27. Failure Scenarios

FailureImpactRecoveryRTO
Single broker crashPartition leader lossISR replica promotion5-10s
Disk failurePartition data loss on nodeRebuild from replicas1-4 hours
Network partitionSplit brain riskMajority partition continues30s
Consumer crashUnprocessed messagesRedeliver from committed offsetImmediate
ZooKeeper failureCannot elect leadersZK ensemble with 3-5 nodes30s

28. Technology Choices (C#)

C# Message Queue Stack

ComponentTechnologyReasoning
Message BrokerApache Kafka / RabbitMQKafka for streaming, RabbitMQ for queues
C# Client (Kafka)Confluent.KafkaOfficial, high performance
C# Client (RabbitMQ)RabbitMQ.ClientOfficial, AMQP 0-9-1
AbstractionMassTransit / NServiceBusBroker-agnostic, sagas, routing
SerializationSystem.Text.Json / ProtobufFast, schema evolution

NuGet Packages

<PackageReference Include="Confluent.Kafka" Version="2.3.*" />
<PackageReference Include="RabbitMQ.Client" Version="6.8.*" />
<PackageReference Include="MassTransit" Version="8.2.*" />
<PackageReference Include="MassTransit.Kafka" Version="8.2.*" />
<PackageReference Include="MassTransit.RabbitMQ" Version="8.2.*" />

29. Alternatives & Trade-offs

SystemModelOrderingBest For
Apache KafkaLog-based pub/subPer-partitionEvent streaming, high throughput
RabbitMQAMQP queueFIFO per queueTask queues, routing
AWS SQSManaged queueBest-effortAWS-native, simple queues
Google Pub/SubManaged streamingPer-keyGCP-native, global
NATS JetStreamLog-basedPer-streamLow-latency, edge
Redis StreamsLog-basedPer-streamAlready using Redis

30. Real-World Case Studies

LinkedIn Kafka

  • Scale: 2 trillion messages/day, 7M msg/sec peak.
  • Use cases: Activity tracking, metrics, log aggregation, CDC.
  • Lesson: A simple append-only log is a remarkably versatile abstraction.

Uber Kafka

  • Scale: 7M events/sec, 100+ trillion messages/day.
  • Use cases: Ride matching, ETA, payment processing, fraud detection.
  • Lesson: Schema evolution is critical for long-lived event streams.

Netflix SQS + Kafka

  • SQS: Simple task queues for encoding, email, billing.
  • Kafka: Event streaming for activity, recommendations, A/B testing.
  • Lesson: Use the right tool for the right job.

31. Interview Follow-ups

QuestionKey Points
How do you handle message ordering?Per-partition ordering. Hash-based partitioning ensures same key to same partition.
What if a consumer crashes mid-processing?At-least-once: redeliver from last committed offset. Consumer must be idempotent.
How do you achieve exactly-once?Idempotent producer + transactional consume-produce + atomic offset commit.
Kafka vs RabbitMQ?Kafka: log-based, replay, high throughput. RabbitMQ: AMQP queues, complex routing.
How do you handle backpressure?Pull-based consumers (Kafka) or prefetch limits (RabbitMQ).
What happens when a broker dies?ISR replica promoted. Unavailable briefly. Messages in unacknowledged writes may be lost.

32. Senior/Staff/Principal Discussion

Architectural Decisions

  • Event streaming vs traditional queue: Kafka for event sourcing, replay, and stream processing. RabbitMQ/SQS for simple task distribution.
  • Partition count: More partitions = more parallelism but more metadata overhead. Start with 6-12, increase if needed.
  • Exactly-once vs at-least-once: Exactly-once adds complexity. For most use cases, at-least-once with idempotent consumers is sufficient.

Operational Considerations

  • Partition reassignment: When adding brokers, move partitions during low-traffic periods.
  • Schema evolution: Use a schema registry with backward-compatible evolution.
  • Consumer group management: Monitor group membership, heartbeats, and rebalance frequency.

33. Architecture Evolution

Message Queue Evolution

graph TB subgraph "Phase 1: Simple" P1[In-process queue] --> P2[Single RabbitMQ] end subgraph "Phase 2: Distributed" P3[Kafka cluster] --> P4[Event-driven architecture] end subgraph "Phase 3: Streaming" P5[Stream processing] --> P6[Event sourcing + CQRS] end subgraph "Phase 4: Global" P7[Multi-datacenter replication] --> P8[Global event mesh] end P1 --> P3 --> P5 --> P7

34. Key Takeaways

Core Principles

  1. At-least-once delivery is the practical default — consumers must be idempotent.
  2. Append-only log is the fundamental data structure — ordering, replay, durability.
  3. Partitioning enables horizontal scaling — more partitions = more parallelism.
  4. Consumer groups distribute work — each partition assigned to one consumer.
  5. Replication provides durability — ISR ensures data safety.
  6. Batching and zero-copy are the key performance optimizations.
  7. Dead letter queues handle poison messages — prevent infinite retry loops.
  8. Schema registry enables safe schema evolution.
  9. Consumer lag is the most important metric — measures how far behind consumers are.
  10. Choose the right tool: Kafka for streaming, RabbitMQ for queues, SQS for simple managed.

35. References

  • Designing Data-Intensive Applications (Kleppmann, 2017)
  • Apache Kafka: The Definitive Guide (Shapira et al., 2021)
  • Kafka: a Distributed Messaging System for Log Processing (Kreps et al., 2011)
  • RabbitMQ in Depth (Lindholm, 2016)
  • Confluent.Kafka Documentation
  • MassTransit Documentation
  • The Log: What every software engineer should know (Kreps, 2011)
  • Building Event-Driven Microservices (Adam Bellemare, 2020)

36. Message Queue Monitoring and Alerting Dashboard

Comprehensive monitoring of a distributed message queue requires tracking producer throughput, consumer lag, broker health, and end-to-end latency. The monitoring system must detect anomalies in real-time and trigger alerts before users notice degraded performance.

public class MessageQueueMonitor
{
    private readonly IKafkaAdminClient _adminClient;
    private readonly IMetricsCollector _metrics;
    private readonly IAlertingService _alerting;

    public async Task<QueueHealthReport> GetHealthReportAsync(string topic)
    {
        var consumerGroups = await _adminClient.ListConsumerGroupsAsync(topic);
        var maxLag = 0L;
        var lagByGroup = new Dictionary<string, long>();

        foreach (var group in consumerGroups)
        {
            var offsets = await _adminClient.GetConsumerOffsetsAsync(group, topic);
            var lag = offsets.Sum(o => o.Lag);
            lagByGroup[group] = lag;
            maxLag = Math.Max(maxLag, lag);
        }

        var brokerMetrics = await _adminClient.GetBrokerMetricsAsync(topic);

        var report = new QueueHealthReport
        {
            Topic = topic,
            PartitionCount = brokerMetrics.PartitionCount,
            TotalMessages = brokerMetrics.TotalMessages,
            MaxConsumerLag = maxLag,
            LagByConsumerGroup = lagByGroup,
            ProducerThroughput = brokerMetrics.ProducerThroughput,
            BrokerHealth = brokerMetrics.Brokers.Select(b => new BrokerHealth
            {
                BrokerId = b.Id,
                IsAlive = b.IsAlive,
                DiskUsagePercent = b.DiskUsagePercent,
                CpuUsagePercent = b.CpuUsagePercent
            }).ToList()
        };

        if (maxLag > 1_000_000)
        {
            await _alerting.SendAlertAsync(new Alert
            {
                Severity = AlertSeverity.Critical,
                Title = $"High consumer lag on {topic}",
                Message = $"Max lag: {maxLag:N0} messages"
            });
        }

        return report;
    }
}

Key Monitoring Metrics

MetricDescriptionAlert Threshold
Consumer LagMessages behind latest offset> 1M messages
Producer ThroughputMessages/sec published< 50% of baseline
End-to-End LatencyTime from produce to consume> 5 seconds
Broker Disk Usage% of disk capacity used> 85%
ISR Shrink RateIn-sync replicas falling behind> 0 partitions
Request Latency P9999th percentile request time> 500ms
Error RateFailed requests per second> 0.1%

Dead Letter Queue and Message Recovery

Messages that repeatedly fail processing must be routed to a Dead Letter Queue (DLQ) to prevent poison pill messages from blocking the queue. The DLQ provides a mechanism for operators to inspect, debug, and replay failed messages. A robust DLQ strategy includes automatic classification of failure types, configurable retry policies, and bulk replay capabilities.

public class DeadLetterQueueHandler
{
    private readonly IQueueProducer _dlqProducer;
    private readonly IQueueProducer _retryProducer;
    private readonly IMetricsCollector _metrics;

    public async Task<HandlerResult> HandleFailedMessageAsync(
        QueueMessage message, Exception error, int attemptCount)
    {
        var classification = ClassifyError(error);

        if (classification.IsTransient && attemptCount < MaxRetries)
        {
            var delay = CalculateExponentialBackoff(attemptCount);
            await _retryProducer.ProduceAsync(message, delay);

            _metrics.IncrementCounter("queue.retry",
                new Dictionary<string, string>
                {
                    ["topic"] = message.Topic,
                    ["attempt"] = attemptCount.ToString()
                });

            return HandlerResult.RetryScheduled;
        }

        // Route to DLQ
        var dlqMessage = new DeadLetterMessage
        {
            OriginalMessage = message,
            ErrorType = classification.Type,
            ErrorMessage = error.Message,
            StackTrace = error.StackTrace,
            AttemptCount = attemptCount,
            FirstFailedAt = message.CreatedAt,
            LastFailedAt = DateTimeOffset.UtcNow,
            Topic = message.Topic,
            Partition = message.Partition,
            Offset = message.Offset
        };

        await _dlqProducer.ProduceAsync("dlq", dlqMessage);

        _metrics.IncrementCounter("queue.dlq",
            new Dictionary<string, string>
            {
                ["topic"] = message.Topic,
                ["error_type"] = classification.Type.ToString()
            });

        return HandlerResult.SentToDlq;
    }

    private ErrorClassification ClassifyError(Exception error)
    {
        return error switch
        {
            TimeoutException => new ErrorClassification
            {
                Type = ErrorType.Transient, IsTransient = true },
            HttpRequestException => new ErrorClassification
            {
                Type = ErrorType.Transient, IsTransient = true },
            JsonException => new ErrorClassification
            {
                Type = ErrorType.Deserialization, IsTransient = false },
            InvalidOperationException => new ErrorClassification
            {
                Type = ErrorType.BusinessLogic, IsTransient = false },
            _ => new ErrorClassification
            {
                Type = ErrorType.Unknown, IsTransient = false }
        };
    }
}

36. Conclusion

Distributed message queues are the backbone of modern async architectures. They decouple services, absorb traffic spikes, provide fault tolerance, and enable event-driven patterns that scale independently. The choice between a traditional message queue (RabbitMQ, SQS) and an event streaming platform (Kafka, Kinesis) depends on whether you need simple task distribution or durable, replayable event logs.

The key insight is that most systems need both paradigms: Kafka for high-throughput event streaming and analytics, and a traditional queue for simple task distribution and request-reply patterns. Understanding the trade-offs — ordering vs throughput, durability vs latency, complexity vs features — lets you choose the right tool for each use case.

Quick Recap

  1. Delivery: At-least-once with idempotent consumers
  2. Data structure: Append-only log with sequential offsets
  3. Scaling: Partitioning + consumer groups
  4. Durability: Replication + disk persistence
  5. Ordering: Per-partition, per-key
  6. Performance: Batching + zero-copy + compression

© 2025 Ayodhyya - The System Design Codex. All rights reserved.

This is part of "The Complete System Design Interview Handbook" series.