If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- Subscription is the unit of work. Not the Subscriber. Each subscription owns its queue, its worker thread, and its retry state, which is what isolates one slow consumer from everyone else.
- Publish never blocks. It offers the message into a bounded queue and returns. A subscriber that takes two seconds cannot stall the broker or the publishers behind it.
- One worker thread per subscription. That single call makes per-subscriber ordering free: messages leave the queue in the exact order they entered, and only one thread ever touches the handler.
- At-least-once with retry and a dead-letter sink. Ack-or-retry with capped backoff, then give up to a DLQ so a poison message never jams the pipe. Handlers must be idempotent.
- Bounded queue is the backpressure. When the buffer fills, the policy (drop, block, or spill) is a knob, chosen by whether the payload is a metric or an order.
1Clarify the requirements#
Do not draw a single class until you know what "pub-sub" means to this interviewer. The term covers everything from a tiny in-process event bus to Kafka with 1000 partitions, and the scope you assume silently is the scope you get dinged on.
Candidate: Before I draw anything: in-process or distributed? For an LLD round I would assume a single-process, in-memory broker in one JVM. A distributed log with partitions and replication is a different round entirely.
Interviewer: Single process, in-memory. Keep it to one JVM.
Candidate: Delivery guarantee? At-most-once, at-least-once, or exactly-once? That one answer drives acks, retries, and dedup.
Interviewer: At-least-once is fine. Treat the other two as follow-ups.
Candidate: Ordering, and fan-out? I would guarantee per-topic FIFO for a single subscriber, and support both broadcast and consumer-group semantics.
Interviewer: Good. Now design it so a slow subscriber cannot take the broker down.
What those answers just told me:
- The round is about concurrency, not a message map. "A slow subscriber cannot take the broker down" is the whole thesis handed to me.
- At-least-once means acks and retries are load-bearing, and handlers have to be idempotent. I will say that out loud.
- Per-subscriber FIFO, not global ordering. Global ordering across topics would force one serialization point and kill throughput, so I will not promise it.
Functional requirements
- Create and delete topics.
- Publish a message to a topic.
- Subscribe and unsubscribe a consumer to a topic.
- Broadcast fan-out and consumer-group (queue) fan-out.
- At-least-once delivery with ack and retry.
- Per-subscriber, per-topic ordering.
Non-functional requirements
- Thread-safe. Many publishers and subscribers hit it at once, think dozens of threads.
- Non-blocking publish. A slow consumer must not freeze the broker.
- Backpressure, so a flooded subscriber with a full buffer does not eat all the heap.
- Durability is out of scope for in-memory, but I mark exactly where a write-ahead log plugs in.
I am grading how you reason about shared state, not how many classes you can name. The fastest way to lose me is a Map<String, List<Subscriber>> and a loop that calls everyone synchronously. It demos fine and dies the moment one subscriber is slow, so I will hand you a slow subscriber on purpose. Name the async boundary first, then build toward it.
2Identify the core entities#
Name the nouns, and make each one earn its place. The one most candidates skip is the one that matters most.
- Message carries a payload plus metadata (id, timestamp, topic). Immutable once created, so I can hand the same object to 50 subscribers with zero copying and zero locking.
- Topic is the named channel, say
orders.created. It holds its subscriptions and a fan-out policy. It does not deliver anything itself. - Subscriber is the handler: an interface with one method,
onMessage. The user implements it. This is the only part the caller writes. - Subscription is the link between a Subscriber and a Topic. It owns the per-subscriber state: the inbound queue, the delivery thread, the retry counter, the cursor.
- Broker is the front door. Publishers and subscribers only ever talk to the Broker. It owns the topic registry.
- DeliveryStrategy decides which subscriptions receive a message: all of them (broadcast) or one of them (round-robin for a group).
Separating Subscription from Subscriber is the single biggest tell between a senior and a junior answer. Forget it and you cram per-consumer state into the Topic, and everything tangles the moment two subscribers move at different speeds. The Subscription, not the Subscriber, is where the work happens: each one runs its own delivery loop pulling from its own queue. That one decision is what isolates a slow consumer from every other consumer on the topic.
3Class design#
The ownership story in one breath:
- Broker holds many Topics.
- Topic holds many Subscriptions and one DeliveryStrategy.
- Subscription wraps one Subscriber and owns a queue plus a worker thread.
- Message flows through all of it but belongs to none of it.
Composition versus association, and why I split them:
- Composition where a part cannot outlive its whole: a Subscription makes no sense without its Topic, so the Topic owns it and stops it on removal.
- Association where objects just reference each other: a Subscription points at a Subscriber the user still owns and may reuse elsewhere, so I do not control its lifecycle.
4Key implementation#
Start with the pieces shared across threads, and make them dead simple on purpose. The immutable Message and the one-method Subscriber contract are the ones that cross thread boundaries, so they get no cleverness and a final.
public final class Message {
private final String id;
private final String topic;
private final Object payload;
private final long timestamp;
public Message(String topic, Object payload) {
this.id = UUID.randomUUID().toString();
this.topic = topic;
this.payload = payload;
this.timestamp = System.currentTimeMillis();
}
public String id() { return id; }
public String topic() { return topic; }
public Object payload() { return payload; }
}
@FunctionalInterface
public interface Subscriber {
// Return true to ack. Throw or return false to trigger retry.
boolean onMessage(Message message);
}
The Subscription is the heart of the design. Each one has a bounded queue and a single worker thread draining it.
- One thread per subscription makes per-subscriber ordering trivial: messages come off the queue in the exact order they went in.
- Only one thread ever touches that handler, so there is no reordering and no concurrent-handler race to reason about.
public class Subscription {
private final String id = UUID.randomUUID().toString();
private final Subscriber subscriber;
private final BlockingQueue<Message> queue;
private final int maxRetries;
private final Thread worker;
private volatile boolean running = true;
public Subscription(Subscriber subscriber, int queueSize, int maxRetries) {
this.subscriber = subscriber;
this.queue = new LinkedBlockingQueue<>(queueSize);
this.maxRetries = maxRetries;
this.worker = new Thread(this::drain, "sub-" + id);
this.worker.start();
}
// Non-blocking offer. Returns false when the buffer is full (backpressure).
public boolean enqueue(Message m) {
if (!running) return false;
return queue.offer(m);
}
private void drain() {
while (running) {
Message m;
try {
m = queue.poll(200, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
if (m != null) deliver(m);
}
}
private void deliver(Message m) {
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (subscriber.onMessage(m)) return; // acked
} catch (RuntimeException ex) {
// swallow, fall through to retry
}
backoff(attempt);
}
deadLetter(m); // give up after maxRetries
}
private void backoff(int attempt) {
try { Thread.sleep(Math.min(50L << attempt, 1000)); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
private void deadLetter(Message m) {
System.err.println("DLQ topic=" + m.topic() + " msg=" + m.id());
}
public String id() { return id; }
public void stop() {
running = false;
worker.interrupt();
}
}
Two lines in there are deliberate, and I say why:
- The
pollwith a 200ms timeout lets the worker wake up and recheck therunningflag even when no messages arrive, sostop()actually ends the loop instead of parking forever on a blockingtake(). - The capped exponential backoff (50ms doubling up to a 1000ms ceiling) stops a flapping downstream from spinning the retry at full speed.
take() instead of a timed poll? It would be simpler and burn zero CPU idling.Reveal the answer
stop() needs the worker to notice the running flag. A bare take() parks the thread indefinitely with no message in the queue, so shutdown hangs until the next message arrives. The 200ms poll is the cheap periodic wake-up that makes clean shutdown possible without a separate poison-pill message. Interrupting the thread covers the case where it is mid-poll.The Topic owns its subscriptions and hands the "who gets this" question to a strategy. It never decides fan-out itself.
public class Topic {
private final String name;
private final Map<String, Subscription> subs = new ConcurrentHashMap<>();
private final DeliveryStrategy strategy;
public Topic(String name, DeliveryStrategy strategy) {
this.name = name;
this.strategy = strategy;
}
public String addSubscription(Subscription s) {
subs.put(s.id(), s);
return s.id();
}
public void removeSubscription(String id) {
Subscription s = subs.remove(id);
if (s != null) s.stop();
}
public void route(Message m) {
List<Subscription> targets = strategy.select(
new ArrayList<>(subs.values()), m);
for (Subscription s : targets) {
if (!s.enqueue(m)) {
// Buffer full. Policy choice: drop, block, or spill.
System.err.println("backpressure drop sub=" + s.id());
}
}
}
}
The strategy interface is tiny, and that is where broadcast and queue semantics split cleanly. New routing (sticky partition, weighted) is a new class with zero edits to Topic.
public interface DeliveryStrategy {
List<Subscription> select(List<Subscription> all, Message m);
}
public class BroadcastStrategy implements DeliveryStrategy {
public List<Subscription> select(List<Subscription> all, Message m) {
return all; // everyone gets it
}
}
public class RoundRobinStrategy implements DeliveryStrategy {
private final AtomicInteger cursor = new AtomicInteger();
public List<Subscription> select(List<Subscription> all, Message m) {
if (all.isEmpty()) return List.of();
int i = Math.floorMod(cursor.getAndIncrement(), all.size());
return List.of(all.get(i)); // one consumer in the group
}
}
That floorMod is not cosmetic. A plain % goes negative once the AtomicInteger overflows past Integer.MAX_VALUE, which throws an index-out-of-bounds hours into a long run. Writing cursor.getAndIncrement() % all.size() and calling it done is exactly the "works in the demo, pages you at 3am" bug an interviewer loves to catch. Say the overflow out loud.
The Broker ties it together, and publish just routes and returns. It never waits on a subscriber.
public class Broker {
private final Map<String, Topic> topics = new ConcurrentHashMap<>();
private final int queueSize;
private final int maxRetries;
public Broker(int queueSize, int maxRetries) {
this.queueSize = queueSize;
this.maxRetries = maxRetries;
}
public Topic createTopic(String name, DeliveryStrategy strategy) {
return topics.computeIfAbsent(name, n -> new Topic(n, strategy));
}
public void publish(String topicName, Object payload) {
Topic t = topics.get(topicName);
if (t == null) throw new NoSuchElementException("no topic " + topicName);
t.route(new Message(topicName, payload));
}
public String subscribe(String topicName, Subscriber sub) {
Topic t = topics.get(topicName);
if (t == null) throw new NoSuchElementException("no topic " + topicName);
return t.addSubscription(new Subscription(sub, queueSize, maxRetries));
}
public void unsubscribe(String topicName, String subId) {
Topic t = topics.get(topicName);
if (t != null) t.removeSubscription(subId);
}
}
Now watch a single publish, so the async hop is obvious. The publisher is unblocked the instant the message lands in the queue, long before the subscriber actually runs.
enqueue to returns immediately hop. Everything above that line is the publisher's thread and is fast. Everything below runs on the subscription's own worker thread, decoupled, so a slow onMessage is invisible to the publisher.5Design decisions and patterns used#
The patterns I can name and defend:
- Strategy for delivery. Broadcast and round-robin are two policies behind one
selectmethod. Adding sticky-partition or weighted routing later means a new class, zero edits to Topic. This keeps fan-out logic out of the topic's hair and keeps open-closed honest. - Observer, grown up. Pub-sub is the Observer pattern decoupled through a broker. Classic Observer has subjects holding direct references to observers and notifying them synchronously on the caller's thread. Here the Broker sits in the middle, publishers and subscribers never see each other, and delivery is async on a different thread. Naming that difference is a signal interviewers listen for.
- Factory-ish creation through the Broker. Callers never
newa Topic or Subscription.createTopicandsubscribehand back managed objects wired with the right queue size and retry policy, so I can change how subscriptions get their threads in one place. - Producer-consumer inside each Subscription. The bounded
BlockingQueueplus a dedicated worker is the textbook producer-consumer split, and it is exactly what buys non-blocking publish and natural backpressure.
The call I deliberately did not make: one shared thread pool for all deliveries.
- Tempting, and it saves threads.
- But per-subscriber ordering gets hard: two pool threads could run two messages for the same subscriber out of order.
- And one subscriber's slow handler can starve others waiting on the shared threads.
- One worker per subscription trades thread count for clean isolation and dead-simple ordering. I name exactly when I would switch in the follow-ups, and it comes down to subscription count.
Ordering is free when exactly one thread ever touches the handler. Everything clever you add is a way to pay for giving that up.
6Handling edge cases and concurrency#
This problem is a concurrency exam dressed as a design question, and the interviewer is mostly watching how I reason about shared state. The parts I make sure to hit:
- Slow subscriber. The bounded queue is the whole defense. Publish offers and moves on. When the queue fills,
offerreturns false and we hit the backpressure branch. The full-buffer policy is configurable: drop-newest, drop-oldest, or block the publisher for queue semantics where you would rather slow down than lose data. A metric gets dropped, an order gets blocked. - Per-subscriber ordering. Single worker per subscription means messages are handled strictly in enqueue order, because there is exactly one thread. A shared pool would need sequencing keys to pin each subscriber to one thread, which is the Kafka partition trick.
- Unsubscribe during delivery.
removeSubscriptionpulls the subscription from the map and callsstop(), which flipsrunningto false and interrupts the worker. The worker may finish its current message first, and that is fine for at-least-once. TheConcurrentHashMapmakes the removal safe whilerouteiterates, becauserouteworks on a snapshot copy, not the live map. - Subscribe and publish racing. I copy the subscription values into a new list before routing. A subscriber that joins mid-publish might miss that one in-flight message, and for pub-sub that is the correct semantic: you get messages published after you subscribe, not before.
- Handler throws. Caught in
deliver, counted as a failed attempt, retried with capped exponential backoff. AftermaxRetriesthe message goes to a dead-letter sink instead of blocking the queue forever. A poison message must never jam the pipe behind it. - At-least-once reality. If
onMessagesucceeds but the process dies before the ack is recorded, a durable version would redeliver on restart. So handlers must be idempotent. I say this every time, because at-least-once without idempotent consumers is the trap that double-charges customers, and I have watched it happen. - Visibility.
runningisvolatileso the worker sees the stop flag promptly without a lock. The queue is thread-safe by construction, so worker and publisher coordinate through it. No locks on the hot path, which keepspublishcheap and contention-free under dozens of concurrent publishers.
"At-least-once delivery means my handlers have to be idempotent." Say it unprompted. It proves you know the guarantee is not a free gift: the broker promises to deliver at least once, which means sometimes twice, and the consumer is the only place that can make a double delivery harmless. Candidates who promise at-least-once and never mention idempotency have not run one of these in production.
7Extensions and follow-ups#
When the interviewer pushes, these are the ready answers, and having them scoped is what turns a Hire into a Strong Hire:
- Durability. Add a write-ahead log. Append the message before routing, replay unacked entries on startup, give the Subscription a persisted cursor per topic. This is the exact jump from in-memory to Kafka-like, and I point at where it plugs in:
Topic.routeappends before it enqueues. - Consumer groups properly. Round-robin here just rotates a counter. Real groups need partition assignment and rebalancing when a consumer joins or leaves. I would shard the topic into partitions and assign whole partitions to group members, so ordering survives a rebalance.
- Wildcard topics. Subscribe to
orders.*. Swap the flat topic map for a trie and match on publish. This is MQTT-style topic matching. - Backpressure upstream. Signal the publisher when buffers are filling so it can throttle, rather than silently dropping. A credit-based scheme where the subscriber hands back permits is the clean version.
- Scaling the thread model. Thousands of subscriptions means thousands of OS threads, which will not fly past a few thousand. I would move to a shared pool keyed by subscription id so ordering still holds, or go event-loop with virtual threads (Project Loom), a clean fit because the worker loop is mostly blocked on the queue anyway.
- Exactly-once. Dedup by message id on the consumer plus idempotent writes. True end-to-end exactly-once is expensive and usually just good-enough at-least-once plus idempotency. I push back on whether they really need it before building it.
"A subscriber's handler takes two seconds per message. What happens to the publisher?"
"The slow subscriber's queue fills up. Now what?"
"A handler throws every time on one message. Does it block the topic forever?"
"You promised at-least-once. What does that demand of the people writing handlers?"
"You've got 50,000 subscriptions. One OS thread each will not fly. What changes?"
What you can now explain in the room
- Why the Subscription, not the Subscriber, owns the queue, thread, and retry state.
- Why `publish` is non-blocking and exactly where the async hop happens.
- How one worker thread per subscription makes per-subscriber ordering free.
- How at-least-once, retries, a dead-letter sink, and idempotent handlers fit together.
- Why the bounded queue is the backpressure, and how the full-buffer policy depends on the data.
- Where the design stops being in-memory and a write-ahead log turns it into Kafka.
Related: the Observer pattern, the Strategy pattern, and the producer-consumer problem. If you liked this, try "Design a Rate Limiter" and "Design an In-Memory Message Queue" next.
Get a prep plan built around your Design a Pub-Sub System interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.