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

#RequirementDescription
1Create/Delete TopicsManage named message channels
2Publish MessagesPublishers send messages to specific topics
3Subscribe/UnsubscribeConsumers register interest in topics
4Message DeliveryDeliver messages to all active subscribers of a topic
5Message OrderingMaintain order within a single topic
6AcknowledgmentSubscribers confirm message processing
7Dead Letter QueueHandle messages that fail processing repeatedly
8Message FilteringSubscribers can filter messages by attributes
9Subscriber GroupsSupport competing consumers (load distribution)
10Message ReplayAllow re-reading messages from a specific offset

Non-Functional Requirements

RequirementTarget
Throughput100K+ messages/second
Latency< 10ms for publish, < 50ms for delivery
DurabilityMessages not lost after acknowledgment by broker
Delivery GuaranteeConfigurable (at-least-once default)
Availability99.9% uptime
ScalabilityHorizontal 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

EntityResponsibilityKey Attributes
MessageBrokerCentral orchestrator, manages topicstopics, subscriberGroups
TopicNamed channel for messagesname, messages, subscribers, retentionPolicy
MessageUnit of communicationid, payload, timestamp, headers, key
PublisherProduces messagesid, name
SubscriberConsumes messagesid, name, subscribedTopics, offset
SubscriptionBinding between subscriber and topicsubscriber, topic, filter, offset, group
SubscriberGroupCompeting consumers sharing loadgroupId, members, offsets
MessageQueueInternal buffer per subscriber/groupmessages, capacity, head, tail
DeadLetterQueueFailed messages storagemessages, retryCount, originalTopic
FilterMessage selection criteriaattribute, operator, value
AcknowledgmentTrackerTracks processed messagessubscriberId, 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

AspectPush ModelPull Model
MechanismBroker pushes to subscriberSubscriber polls broker
LatencyLower (immediate delivery)Higher (polling interval)
BackpressureHard (can overwhelm subscriber)Natural (subscriber controls rate)
ComplexityBroker manages connectionsSimpler broker, complex client
Offline HandlingNeeds bufferingNatural (messages wait)
Use CaseReal-time notificationsBatch processing, variable load
ExampleRabbitMQ (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

SemanticGuaranteeImplementationUse Case
At-most-onceMay lose messagesFire and forget, no ackMetrics, logs
At-least-onceNo loss, may duplicateAck required, retry on timeoutMost applications
Exactly-onceNo loss, no duplicatesIdempotency keys + transactionsFinancial, critical

Decision: Default to at-least-once. Provide idempotency support for exactly-once semantics where needed.

3. Message Ordering

StrategyGuaranteeTrade-off
Total OrderAll messages globally orderedSingle writer bottleneck
Topic OrderOrdered within a topicGood balance
Partition OrderOrdered within a partition (by key)Best throughput
No OrderBest-effortMaximum parallelism

Decision: Guarantee ordering within a topic. Use message keys for partition-level ordering when subscriber groups are used.

4. Message Persistence

ApproachDurabilityPerformanceComplexity
In-memory onlyLost on crashFastestLowest
Write-ahead logSurvives crashFast (sequential I/O)Medium
DatabaseFull ACIDSlowestHighest

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

PatternApplicationBenefit
ObserverCore pub/sub mechanism - topic notifies subscribersDecouples publishers from subscribers
StrategyDelivery policies (push/pull), routing strategiesSwappable delivery mechanisms
FactoryTopic and subscription creationEncapsulates creation logic
Chain of ResponsibilityMessage filters (attribute filter → content filter → priority filter)Composable filtering
CommandMessage as a command object with metadataEnables queuing, logging, replay
IteratorSequential message consumption with offset trackingClean traversal abstraction

Edge Cases

Edge CaseProblemHandling Strategy
Slow SubscriberBacks up message queue, memory pressureBounded queue + backpressure signal or drop policy
Subscriber CrashMessages in-flight lostRedelivery after ack timeout, track last acked offset
Ordering with Multiple PublishersInterleaved messagesUse timestamps or sequence numbers, single-writer per partition
Topic Deletion with Active SubscribersSubscribers left danglingNotify subscribers, close subscriptions, grace period
Poison MessagesMessage always fails processingRetry limit → move to dead letter queue
Duplicate MessagesNetwork retry causes re-publishDeduplication using message ID (idempotency)
Subscriber Group RebalancingMember joins/leaves during processingPause consumption, reassign partitions, resume
Memory ExhaustionToo many retained messagesRetention policy enforcement (time/size-based eviction)
Out-of-Order AcknowledgmentSubscriber acks message N+1 but not NCumulative ack (ack N+1 implies all ≤ N) or individual tracking

Extensibility Points

ExtensionDescriptionDesign Impact
Message TransformationTransform message format between publisher and subscriberAdd Transformer interface in delivery pipeline
Routing RulesContent-based routing to specific subscribersExtend Filter with routing predicates
Priority QueuesHigh-priority messages delivered firstPriority queue implementation per subscriber
Scheduled DeliveryDeliver messages at a future timeDelay queue with timer-based promotion
Message ReplayRe-read historical messagesOffset-based consumption, retain messages
Multi-tenancyIsolated topics per tenantNamespace prefixing, quota management
Distributed ModeScale across multiple nodesPartitioning, replication, leader election
Schema RegistryValidate message formatSchema 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:

  1. Bounded buffer with backpressure - When subscriber's queue is full, signal publisher to slow down (flow control)
  2. Drop policy - Drop oldest messages when buffer is full (acceptable for metrics/monitoring)
  3. Persistent overflow - Spill to disk when memory buffer is full, read back when subscriber catches up
  4. 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:

  1. Broker side - Assign unique message IDs, deduplicate on publish (producer retries don't create duplicates)
  2. Consumer side - Track processed message IDs, skip duplicates. Use idempotent operations (e.g., SET vs INCREMENT)
  3. 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:

  1. Messages with the same key always go to the same partition
  2. Each partition is consumed by exactly one consumer in the group
  3. Ordering is guaranteed within a partition (same key = same consumer = ordered)
  4. 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