Chapter 11 · Article 49 of 55
PubSub System Design
Design a publish-subscribe messaging system where publishers send messages to topics and subscribers receive messages from topics they're subscribed to. The system should decoup…
Article outline13 sections on this page+
Problem Statement
Design a publish-subscribe messaging system where publishers send messages to topics and subscribers receive messages from topics they're subscribed to. The system should decouple message producers from consumers, support multiple topics, and guarantee message delivery. Think of a simplified version of Apache Kafka or RabbitMQ.
Interviewer's framing: "Design a message broker that supports publish-subscribe semantics. Publishers push messages to named topics, and subscribers receive messages from topics they've registered interest in."
Requirements
Functional Requirements
| # | Requirement | Description |
|---|---|---|
| 1 | Create/Delete Topics | Manage named message channels |
| 2 | Publish Messages | Publishers send messages to specific topics |
| 3 | Subscribe/Unsubscribe | Consumers register interest in topics |
| 4 | Message Delivery | Deliver messages to all active subscribers of a topic |
| 5 | Message Ordering | Maintain order within a single topic |
| 6 | Acknowledgment | Subscribers confirm message processing |
| 7 | Dead Letter Queue | Handle messages that fail processing repeatedly |
| 8 | Message Filtering | Subscribers can filter messages by attributes |
| 9 | Subscriber Groups | Support competing consumers (load distribution) |
| 10 | Message Replay | Allow re-reading messages from a specific offset |
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Throughput | 100K+ messages/second |
| Latency | < 10ms for publish, < 50ms for delivery |
| Durability | Messages not lost after acknowledgment by broker |
| Delivery Guarantee | Configurable (at-least-once default) |
| Availability | 99.9% uptime |
| Scalability | Horizontal scaling of topics and subscribers |
Constraints & Assumptions
- Single-node system for LLD (distributed concerns mentioned for extensibility)
- Messages are serialized byte arrays (content-agnostic)
- Maximum message size: 1MB
- Topic retention: configurable (time-based or size-based)
- Subscribers can be online or offline (messages buffered)
- Thread-safe operations required (multiple publishers/subscribers concurrently)
Key Entities & Relationships
| Entity | Responsibility | Key Attributes |
|---|---|---|
| MessageBroker | Central orchestrator, manages topics | topics, subscriberGroups |
| Topic | Named channel for messages | name, messages, subscribers, retentionPolicy |
| Message | Unit of communication | id, payload, timestamp, headers, key |
| Publisher | Produces messages | id, name |
| Subscriber | Consumes messages | id, name, subscribedTopics, offset |
| Subscription | Binding between subscriber and topic | subscriber, topic, filter, offset, group |
| SubscriberGroup | Competing consumers sharing load | groupId, members, offsets |
| MessageQueue | Internal buffer per subscriber/group | messages, capacity, head, tail |
| DeadLetterQueue | Failed messages storage | messages, retryCount, originalTopic |
| Filter | Message selection criteria | attribute, operator, value |
| AcknowledgmentTracker | Tracks processed messages | subscriberId, lastAckedOffset |
Relationships
Publisher ──publishes──> Topic
Topic ──delivers──> Subscription ──routes──> Subscriber
Subscription ──uses──> Filter (optional)
Subscriber ──belongs to──> SubscriberGroup (optional)
Topic ──overflow──> DeadLetterQueue
MessageBroker ──manages──> Topic (1:N)
Topic ──contains──> Message (1:N, ordered)
ASCII Class Diagram
+--------------------------------------------------+
| MessageBroker |
+--------------------------------------------------+
| - topics: Map<String, Topic> |
| - subscriberGroups: Map<String, SubscriberGroup> |
+--------------------------------------------------+
| + createTopic(name): Topic |
| + deleteTopic(name): void |
| + publish(topicName, message): void |
| + subscribe(subscriberId, topicName, filter): void|
| + unsubscribe(subscriberId, topicName): void |
| + poll(subscriberId, topicName): Message |
| + acknowledge(subscriberId, messageId): void |
+--------------------------------------------------+
| |
| manages | manages
v v
+-------------------------+ +------------------------+
| Topic | | SubscriberGroup |
+-------------------------+ +------------------------+
| - name: String | | - groupId: String |
| - messages: List<Msg> | | - members: List<Sub> |
| - subscriptions: List | | - offsets: Map |
| - retentionPolicy | +------------------------+
| - currentOffset: Long | | + addMember(sub): void |
+-------------------------+ | + getNextConsumer(): Sub|
| + addMessage(msg): void | | + rebalance(): void |
| + getMessages(offset, | +------------------------+
| count): List<Msg> |
| + addSubscription(): void|
+-------------------------+
|
| contains
v
+-------------------------+ +------------------------+
| Message | | Subscription |
+-------------------------+ +------------------------+
| - id: String | | - subscriber: Sub |
| - payload: byte[] | | - topic: Topic |
| - timestamp: Long | | - filter: Filter |
| - headers: Map | | - offset: Long |
| - key: String | | - groupId: String |
+-------------------------+ +------------------------+
| + matches(msg): bool |
| + advance(): void |
+------------------------+
+-------------------------+ +------------------------+
| Publisher | | Subscriber |
+-------------------------+ +------------------------+
| - id: String | | - id: String |
| - name: String | | - subscriptions: List |
+-------------------------+ | - messageQueue: Queue |
| + publish(topic, msg) | +------------------------+
+-------------------------+ | + receive(): Message |
| + acknowledge(id): void|
+------------------------+
+-------------------------+
| DeadLetterQueue |
+-------------------------+
| - messages: List<Msg> |
| - maxRetries: int |
+-------------------------+
| + add(msg, reason): void|
| + retry(msg): void |
| + purge(): void |
+-------------------------+
Design Decisions & Trade-offs
1. Push vs Pull Delivery Model
| Aspect | Push Model | Pull Model |
|---|---|---|
| Mechanism | Broker pushes to subscriber | Subscriber polls broker |
| Latency | Lower (immediate delivery) | Higher (polling interval) |
| Backpressure | Hard (can overwhelm subscriber) | Natural (subscriber controls rate) |
| Complexity | Broker manages connections | Simpler broker, complex client |
| Offline Handling | Needs buffering | Natural (messages wait) |
| Use Case | Real-time notifications | Batch processing, variable load |
| Example | RabbitMQ (default) | Kafka |
Decision: Support both - push for real-time subscribers, pull for batch consumers. Default to pull with long-polling for balance.
2. Delivery Semantics
| Semantic | Guarantee | Implementation | Use Case |
|---|---|---|---|
| At-most-once | May lose messages | Fire and forget, no ack | Metrics, logs |
| At-least-once | No loss, may duplicate | Ack required, retry on timeout | Most applications |
| Exactly-once | No loss, no duplicates | Idempotency keys + transactions | Financial, critical |
Decision: Default to at-least-once. Provide idempotency support for exactly-once semantics where needed.
3. Message Ordering
| Strategy | Guarantee | Trade-off |
|---|---|---|
| Total Order | All messages globally ordered | Single writer bottleneck |
| Topic Order | Ordered within a topic | Good balance |
| Partition Order | Ordered within a partition (by key) | Best throughput |
| No Order | Best-effort | Maximum parallelism |
Decision: Guarantee ordering within a topic. Use message keys for partition-level ordering when subscriber groups are used.
4. Message Persistence
| Approach | Durability | Performance | Complexity |
|---|---|---|---|
| In-memory only | Lost on crash | Fastest | Lowest |
| Write-ahead log | Survives crash | Fast (sequential I/O) | Medium |
| Database | Full ACID | Slowest | Highest |
Decision: In-memory with optional write-ahead log for durability. Configurable per topic.
5. Subscriber Groups (Competing Consumers)
Without Groups (Broadcast): With Groups (Load Balance):
Publisher -> Topic -> Sub A Publisher -> Topic -> Group1 -> Sub A
-> Sub B -> Sub B
-> Sub C -> Sub C
(only ONE of A/B/C gets each message)
All subscribers get ALL messages Messages distributed among group members
Decision: Support both modes. Subscribers without a group get all messages (fan-out). Subscribers in a group share the load (competing consumers).
Design Patterns Used
| Pattern | Application | Benefit |
|---|---|---|
| Observer | Core pub/sub mechanism - topic notifies subscribers | Decouples publishers from subscribers |
| Strategy | Delivery policies (push/pull), routing strategies | Swappable delivery mechanisms |
| Factory | Topic and subscription creation | Encapsulates creation logic |
| Chain of Responsibility | Message filters (attribute filter → content filter → priority filter) | Composable filtering |
| Command | Message as a command object with metadata | Enables queuing, logging, replay |
| Iterator | Sequential message consumption with offset tracking | Clean traversal abstraction |
Edge Cases
| Edge Case | Problem | Handling Strategy |
|---|---|---|
| Slow Subscriber | Backs up message queue, memory pressure | Bounded queue + backpressure signal or drop policy |
| Subscriber Crash | Messages in-flight lost | Redelivery after ack timeout, track last acked offset |
| Ordering with Multiple Publishers | Interleaved messages | Use timestamps or sequence numbers, single-writer per partition |
| Topic Deletion with Active Subscribers | Subscribers left dangling | Notify subscribers, close subscriptions, grace period |
| Poison Messages | Message always fails processing | Retry limit → move to dead letter queue |
| Duplicate Messages | Network retry causes re-publish | Deduplication using message ID (idempotency) |
| Subscriber Group Rebalancing | Member joins/leaves during processing | Pause consumption, reassign partitions, resume |
| Memory Exhaustion | Too many retained messages | Retention policy enforcement (time/size-based eviction) |
| Out-of-Order Acknowledgment | Subscriber acks message N+1 but not N | Cumulative ack (ack N+1 implies all ≤ N) or individual tracking |
Extensibility Points
| Extension | Description | Design Impact |
|---|---|---|
| Message Transformation | Transform message format between publisher and subscriber | Add Transformer interface in delivery pipeline |
| Routing Rules | Content-based routing to specific subscribers | Extend Filter with routing predicates |
| Priority Queues | High-priority messages delivered first | Priority queue implementation per subscriber |
| Scheduled Delivery | Deliver messages at a future time | Delay queue with timer-based promotion |
| Message Replay | Re-read historical messages | Offset-based consumption, retain messages |
| Multi-tenancy | Isolated topics per tenant | Namespace prefixing, quota management |
| Distributed Mode | Scale across multiple nodes | Partitioning, replication, leader election |
| Schema Registry | Validate message format | Schema validation in publish pipeline |
Interview Follow-ups
Q&A Style
Q: How would you handle a subscriber that's much slower than the publisher?
A: Multiple strategies depending on requirements:
- Bounded buffer with backpressure - When subscriber's queue is full, signal publisher to slow down (flow control)
- Drop policy - Drop oldest messages when buffer is full (acceptable for metrics/monitoring)
- Persistent overflow - Spill to disk when memory buffer is full, read back when subscriber catches up
- Subscriber groups - Add more consumers to the group to parallelize processing
The choice depends on whether message loss is acceptable. For critical messages, use persistent overflow + subscriber groups for horizontal scaling.
Q: How do you ensure exactly-once delivery?
A: Exactly-once is achieved through idempotent processing:
- Broker side - Assign unique message IDs, deduplicate on publish (producer retries don't create duplicates)
- Consumer side - Track processed message IDs, skip duplicates. Use idempotent operations (e.g., SET vs INCREMENT)
- Transactional - Atomic commit of message consumption + side effects (e.g., Kafka transactions)
True exactly-once across distributed systems requires end-to-end idempotency, not just broker guarantees.
Q: How would you implement message ordering with multiple consumers in a group?
A: Use key-based partitioning:
- Messages with the same key always go to the same partition
- Each partition is consumed by exactly one consumer in the group
- Ordering is guaranteed within a partition (same key = same consumer = ordered)
- On rebalancing, pause consumption, reassign partitions, resume from last committed offset
Hints for Self-Practice
Q: How would you design the dead letter queue to be useful for debugging?
- Think about what metadata to capture (original topic, failure reason, retry count, timestamps)
- Consider how operators would inspect and replay messages
- Think about alerting when DLQ grows
- Consider automatic retry with exponential backoff before DLQ
Q: How would you handle schema evolution (publisher changes message format)?
- Think about backward/forward compatibility
- Consider schema registry with version tracking
- Think about how consumers handle unknown fields
- Consider Avro/Protobuf schema evolution rules
Counter Questions to Ask Interviewer
- "Should the system support both push and pull delivery, or just one?"
- "What delivery guarantee is required - at-least-once or exactly-once?"
- "Is message ordering critical, or is best-effort acceptable?"
- "Should messages be persisted (survive restarts) or in-memory only?"
- "Do we need subscriber groups (competing consumers) or just broadcast?"
- "What's the expected message throughput and subscriber count?"
- "Should we handle subscriber offline scenarios (message buffering)?"
- "Is there a maximum message retention period?"
References
- "Enterprise Integration Patterns" by Gregor Hohpe & Bobby Woolf - Canonical reference for messaging patterns
- Apache Kafka Design - Log-based messaging, partitions, consumer groups (kafka.apache.org/documentation/design)
- RabbitMQ Concepts - Exchange types, queues, bindings, acknowledgments
- "Designing Data-Intensive Applications" by Martin Kleppmann - Chapter 11: Stream Processing
- AMQP 0-9-1 Specification - Protocol-level pub/sub semantics
- "Release It!" by Michael Nygard - Stability patterns for messaging systems
- Kafka: The Definitive Guide (O'Reilly) - Practical implementation patterns
- Paper: "The Log: What every software engineer should know" - Jay Kreps (LinkedIn), 2013