Skip to content

Design a Pub/Sub Message Broker (Low Level Design)

Build a small in-memory message broker where publishers post to topics, every subscriber gets its own queue and thread, and a slow subscriber never holds up the rest.

Builds on Observer Pattern: Event-Driven Communication and Concurrency basics

Stage 4core11 min read

Problem Statement

Build a tiny message broker that lives inside one Java program. Some code publishes messages to a named topic, like "orders". Other code subscribes to that topic and gets every message published after it joined. The publisher does not know who is listening, and the subscribers do not know who is talking.

Think of a school notice board. A teacher pins a note on the "sports" board. Anyone who follows that board reads it, in their own time. The teacher does not wait for every student to read it before going back to class.

That last part is the whole interview. Anyone can loop over a list of listeners and call them. The hard part is what happens when one listener is slow, when one crashes, when two threads publish at once, and when someone leaves halfway.

Questions to Ask First

QuestionAssumption I will make
One process or many machines?One process. No network, no disk.
Does a new subscriber get old messages?No, only messages published after it subscribes.
Must order be kept?Yes, per topic, for every subscriber. Not across topics.
Can a slow subscriber block the others?No. That is the main requirement.
What if a subscriber throws?Retry a few times, then park the message in a dead letter list.
Do messages survive a restart?No. Everything lives in memory.
Is there a limit on queue size?No limit in this version. I will say what that costs.

Requirements

  • Create topics by name.
  • Publishers send a text message to a topic.
  • Subscribers register for a topic and receive every message published afterwards.
  • Each subscriber receives messages in the order they were published to the topic.
  • Delivery to one subscriber is independent of the others. A slow or failing subscriber must not delay anyone else.
  • A subscriber can unsubscribe and stops receiving new messages.
  • A failing delivery is retried up to 3 times, then the message goes to that subscriber's dead letter list.
  • Safe to use from many threads at once.

Core Classes

Loading diagram…

Seven files, all in one package. Message is a small immutable record. Subscriber is a one-method interface, so in the demo each subscriber is a lambda. Subscription is the interesting one: it is one subscriber's private mailbox plus the thread that empties it.

How the Design Works

The shape of the flow

A publisher calls broker.publish("orders", "shop", "order-1"). The broker finds the Topic. The topic stamps a sequence number on the message, then drops a copy into the mailbox of every subscription. That is all the publisher thread does. It never runs subscriber code.

Each mailbox has its own thread that takes messages out one by one and calls the subscriber.

Loading diagram…

One queue and one thread per subscriber

This is the choice that matters, so here is why. A queue is a line where messages wait their turn. If all subscribers shared one line, the slowest one would hold up everyone behind it. Like a single bank counter: one customer with a bag of coins stops the whole branch.

Giving each subscriber its own line means a slow one only makes its own line longer. In code, the line and the thread come together in one call:

this.worker = Executors.newSingleThreadExecutor(task -> new Thread(task, "sub-" + name));

A single-thread executor has a queue inside it and exactly one worker thread. Tasks run one at a time in the order they were added. That gives us "own queue" and "in order" for free, with no extra code.

The other choice is a shared pool of threads, say 8, used by all subscribers. It uses fewer threads when you have thousands of subscribers. But a pool can run message 2 before message 1 finishes, because two threads pick them up at once. To keep order you then need extra code that says "only one task at a time per subscriber", and at that point you have rebuilt a single-thread executor by hand.

My rule: one thread per subscriber while subscribers number in the dozens. If you have thousands, move to a shared pool plus a per-subscriber queue with a flag that says "a worker is already draining me". Say that out loud in the interview. It shows you know where the simple version stops.

Ordering per topic

If two publishers post to "orders" at the same moment, who is first? The topic decides, and it decides under a lock:

public synchronized Message publish(String publisher, String payload) {
    Message message = new Message(name, nextSequence++, publisher, payload);
    for (Subscription subscription : subscriptions) {
        subscription.enqueue(message);
    }
    return message;
}

Picking the sequence number and putting the message into every queue happen as one step. Without the lock, thread A could get number 1, thread B could get number 2, B could reach the queues first, and some subscriber would see 2 before 1. With the lock, the order in every queue is the same as the sequence numbers.

The lock is held for a very short time. enqueue only adds to a queue and returns. It does not run subscriber code. If it did, one slow subscriber would hold the topic lock and block every publisher.

Order is only promised inside one topic. Messages on "orders" and "payments" can arrive in any relative order. Promising global order would need one lock for the whole broker, and nobody asks for that.

Delivery, retry and dead letters

The worker thread runs this for each message:

private void deliver(Message message) {
    for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
        try {
            subscriber.onMessage(message);
            return;
        } catch (RuntimeException failed) {
            // fall through and try again
        }
    }
    deadLetters.add(message);
}

If the subscriber throws, we try again right away, up to 3 times. After that the message goes to a list of dead letters, which you can read later, and the worker moves on to the next message. A bad message must not kill the thread, or the subscriber would silently stop receiving anything.

Retrying on the spot has a side effect worth knowing: the subscriber's line is paused during the retries. That is on purpose. If we moved the failed message to the back, the subscriber would see message 3 before message 2, and we promised order.

Unsubscribe

public synchronized void remove(Subscription subscription) {
    if (subscriptions.remove(subscription)) {
        subscription.close();
    }
}

Removing takes the same lock as publishing. So after unsubscribe returns, no later publish can reach that subscriber. Then close() calls shutdown() on its executor, which stops accepting new work but still finishes what is already queued. So a subscriber that leaves still gets the messages sent before it left. If you want "stop right now and throw away the queue", swap in shutdownNow(). Both are fair answers. Just pick one and say why.

Shutdown

broker.shutdown() stops accepting publishes, closes every subscription, and waits (up to 10 seconds each) for the queues to empty. This is also how the demo stays deterministic: there is no sleeping, we simply wait for the workers to finish.

Patterns Used and Why

Observer is the base idea. A subject (the topic) keeps a list of observers and tells them when something happens. That is exactly Topic holding Subscription objects.

But this is not the plain textbook observer. Compare them:

Plain observerPub/sub broker
Who knows whomSubject holds observers directlyPublisher only knows a topic name
CallsSubject calls observers on its own threadEach subscriber is called on its own thread
Slow observerBlocks the subject and the othersOnly blocks itself
FailureAn exception can break the notify loopCaught per subscriber, then retry
CouplingSame process, same object graphCan be split across machines later

In short, plain observer is a method call. Pub/sub puts a mailbox between the two sides.

Strategy shows up in a small way. Subscriber is an interface, so each subscriber brings its own behaviour and the broker never changes.

Producer and consumer is the pattern inside each Subscription: the topic thread produces into a queue, the worker thread consumes from it.

I did not add a Factory or a Builder. Nothing here needs them, and adding patterns just to name them is a common way to lose marks.

Walk Through One Full Example

The demo in Main.java creates two topics and six subscribers:

  • email is fast and records what it gets.
  • analytics is slow on purpose. It will not process anything until email has received order-3.
  • audit throws on the first two attempts at order-2, then works.
  • fragile always throws on order-3.
  • trial is unsubscribed right after the first message.
  • ledger listens to the other topic, payments.

The slow subscriber is the proof that delivery is independent. It waits on a latch, which is a gate that opens once. The gate is opened by email, which runs on a different thread. If the broker used one shared thread, analytics would wait for a gate that could never open, and the program would hang. It does not hang, so the lines really are separate.

Real output:

== Publishing ==
shop published orders#1 order-1
trial unsubscribed, orders now has 4 subscribers
shop published orders#2 order-2
shop published orders#3 order-3
bank published payments#1 pay-1

== What each subscriber received ==
email      [order-1, order-2, order-3]
analytics  [order-1, order-2, order-3]
audit      [order-1, order-2, order-3]
fragile    [order-1, order-2]
trial      [order-1]
ledger     [pay-1]

audit attempts on order-2: 3
fragile dead letters: [orders#3 order-3]

Reading it line by line:

  1. email and analytics both got all three in order, even though analytics started late.
  2. audit got order-2 after 3 attempts (2 failures, then success), and the order stayed 1, 2, 3.
  3. fragile never got order-3. It tried 3 times and the message is in its dead letter list. order-1 and order-2 were not affected.
  4. trial got only order-1. It was queued before the unsubscribe, so it was still delivered.
  5. ledger on the other topic had its own sequence, payments#1.

I ran the demo several times and the output is the same each time. The only things that vary between runs are which thread runs when, and that is not printed.

Edge Cases

  • Publish to a topic nobody listens to. The message is dropped. With no subscribers there is nobody to queue for. A real broker would keep a log so late subscribers can catch up.
  • Publish to a topic that does not exist. We throw IllegalArgumentException. Auto-creating topics is friendlier, but it hides typos.
  • Publish after shutdown(). We throw IllegalStateException instead of silently losing the message.
  • Subscriber throws every time. After 3 attempts the message goes to dead letters and the worker keeps going.
  • Subscriber blocks forever. The retry loop cannot help, because the thread is stuck inside the call. Only that subscriber is affected, but its queue grows. A real system adds a timeout.
  • Subscribe while someone is publishing. add takes the same topic lock, so the new subscriber either sees the message or does not. It never sees half of one.
  • Same subscriber added twice. It would get each message twice, once per subscription. We do not block that.

Follow Up Questions an Interviewer May Ask

"What if one subscriber is so slow that its queue grows without end?"

The queue is unbounded here, so memory grows. Use a bounded queue. Then decide what a full queue means: block the publisher (back pressure), drop the oldest, or drop the newest. Each is a real product choice.

"How would you add replay, so a new subscriber can read old messages?"

Keep an append-only list of messages per topic and give each subscriber an offset, which is just "the number of the next message I want". Delivery becomes "read from my offset, then add one". This is how Kafka works. Slow subscribers just have a smaller offset. The cost is memory or disk for the log.

"How would you do retries with a delay?"

Wait longer after each failure (1s, 2s, 4s). Do not use Thread.sleep in the worker, because it freezes the line. A scheduled executor that re-submits the message later works, but then you must think about order again.

"Can you guarantee exactly once delivery?"

Not honestly. With retries you get at least once, so a subscriber may see the same message twice. In this in-memory version, delivery is at most once on a crash and at least once on exceptions. The usual answer is to make subscribers idempotent, meaning handling the same message twice has the same result as once. A message id helps.

"Why not call subscribers directly from the publisher thread?"

Then publishing time equals the sum of all subscriber times, and one exception can stop the loop. It is simple and fine for a toy, and wrong for anything real.

"Why not a ConcurrentHashMap and no synchronized on the topic?"

A concurrent collection makes each call safe, but it does not make "number it, then queue it" one step. The lock is there for that step, not for the list.

"How do wildcards like orders.* work?"

Match the subscriber's pattern against the topic name when publishing. A trie or a simple regex per subscription is enough for an interview.

Common Mistakes

  • Running subscriber code inside the publish call while holding a lock. One slow subscriber then blocks all publishers.
  • Using one shared executor with several threads and still claiming per-subscriber order. Two threads can finish out of order.
  • Letting an exception kill the worker thread. The subscriber then goes quiet and nobody notices.
  • Putting a failed message at the back of the queue, which breaks order.
  • Forgetting to shut down the executors. The JVM never exits because the worker threads keep it alive.
  • Sleeping in a test to wait for delivery. It is slow and sometimes still wrong. Use a latch, or shut down and wait.
  • Saying "thread safe" without saying what is protected from what.

Where This Is Simplified

Be honest about this in the interview. There is no persistence, so a crash loses everything. Queues are unbounded. Retry has no delay. There are no acknowledgements, so the broker does not know if a subscriber really finished. There is no ordering key inside a topic, and a topic has no partitions, which is how real brokers get more speed. Dead letters are only a list you can read, not a topic you can replay. All of these are fine to name and skip.

How to Run

From the repo root:

javac --release 17 -d /tmp/pubsub-out $(find src/LLD/PubSub/src -name '*.java')
java -cp /tmp/pubsub-out com.blueprint.lld.pubsub.Main

The code needs Java 17 or newer and no libraries. The files are in src/LLD/PubSub/src/com/blueprint/lld/pubsub/: Broker, Topic, Subscription, Subscriber, Message, Publisher and Main.

In this topic

Done with this topic?

How well do you know it?

PreviousDesign a Logging Framework (Low Level Design)
Practise 7 questions