Cruxly

Cruxly › Low-level design › LLD Exercises

Pub/Sub System

publishers and subscribers must not know about each other, and slow subscribers must not block fast ones

Open the interactive lesson

Prompt: design an in-process publish/subscribe system. Publishers publish events to a named topic; any number of subscribers can register for a topic and receive every event published to it after they subscribed. A slow subscriber must not delay delivery to other subscribers of the same topic.

45 minutes. The interesting part isn't the registration bookkeeping — it's that "a slow subscriber must not block others" is a concurrency requirement hiding inside what looks like a simple fan-out loop.

Applying the five moves: nouns are Topic, Subscriber, Event, and — the one candidates miss — a per-subscriber Queue, which only appears once the concurrency requirement is taken seriously rather than treated as a footnote. The invariant is "a subscriber receives every event published to its topic after it subscribed, in publish order, and no subscriber's slowness changes what any other subscriber receives" — that second clause is the whole exercise, and stating it up front is what rules out the naive fan-out loop. The interface seam is Subscriber (a callback contract), which is genuinely the Observer pattern — but note it's arrived at because the prompt requires publishers not to know their subscribers, not because Observer was reached for by name. The concurrency sentence is the design: publish must hand off and return, never invoke callbacks inline on the publishing thread, and the follow-up is what happens when one subscriber's queue fills — drop, block the publisher, or grow unbounded, each with a named consequence.

Scope, and why this is Observer with a concurrency requirement bolted on

In scope: topic-based subscribe/unsubscribe, publishing an event to all current subscribers of a topic, isolating a slow or failing subscriber from others. Out of scope (say it): durability/replay of missed events for a subscriber that joins late (a fundamentally different, much harder guarantee — message queues like Kafka solve this with a persisted log, name the distinction), cross-process pub/sub (this is in-memory, one process), wildcard topic matching.

The base shape is the Observer pattern, arrived at because publishers and subscribers must not know about each other's concrete types — a publisher only knows "some number of parties want this event," and a subscriber only knows "I'll be called with events of this shape." Naming Observer after establishing this decoupling requirement, not before, is what separates recognizing the pattern from reciting it.

The requirement that turns this from a two-hour exercise into a real one: a slow subscriber must not delay delivery to other subscribers, or to the publisher. A naive publish() that iterates subscribers and calls each one synchronously on the calling thread means the publisher's own thread blocks on the slowest subscriber's callback — which means publishing itself, not just some subscriber's experience, degrades to the speed of the worst-behaved listener.

The naive version, and why decoupling requires a queue per subscriber

public class NaiveTopic {
    private final List<Consumer<Event>> subscribers = new ArrayList<>();
    public void subscribe(Consumer<Event> sub) { subscribers.add(sub); }
    public void publish(Event e) {
        for (Consumer<Event> sub : subscribers) {
            sub.accept(e);           // runs on the PUBLISHER'S thread
        }
    }
}

The classes: per-subscriber queues, and what happens when one is overwhelmed

public class Topic {
    private final Map<String, SubscriberWorker> subscribers = new ConcurrentHashMap<>();

    public void subscribe(String subId, Consumer<Event> handler) {
        subscribers.put(subId, new SubscriberWorker(handler));
    }

    public void unsubscribe(String subId) {
        SubscriberWorker w = subscribers.remove(subId);
        if (w != null) w.shutdown();
    }

    /** Enqueues to every subscriber's own queue and returns — never waits
     *  for any subscriber's callback to actually run. */
    public void publish(Event e) {
        subscribers.values().forEach(w -> w.offer(e));
    }
}

final class SubscriberWorker {
    private final BlockingQueue<Event> queue = new LinkedBlockingQueue<>(1000); // bounded
    private final Thread worker;
    private volatile boolean running = true;

    SubscriberWorker(Consumer<Event> handler) {
        worker = new Thread(() -> {
            while (running) {
                try {
                    Event e = queue.poll(1, TimeUnit.SECONDS);
                    if (e != null) handler.accept(e);
                } catch (Exception ex) {
                    // one bad event or a callback exception must not kill the worker thread —
                    // log it and keep draining the queue for this subscriber
                }
            }
        });
        worker.start();
    }

    /** Non-blocking offer: if this subscriber's queue is full, drop rather
     *  than block the publisher — a bounded queue with a stated drop policy
     *  beats an unbounded one that hides a slow-consumer problem as memory growth. */
    void offer(Event e) {
        if (!queue.offer(e)) { /* count the drop; do not block publish() */ }
    }

    void shutdown() { running = false; worker.interrupt(); }
}

Each SubscriberWorker has a bounded queue with a non-blocking offer, not an unbounded queue. An unbounded queue for a permanently-slow subscriber doesn't fail loudly — it grows forever and eventually exhausts memory, which is a worse failure than dropping events with a counted, observable signal. Bounding the queue and choosing a stated drop policy (drop newest, in this version) turns an invisible memory leak into a visible, alertable metric.

A single bad event or a throwing callback must not kill the worker thread, or that subscriber silently stops receiving anything from that point forward with no crash to notice — catching inside the worker loop and continuing is what keeps one malformed event from becoming a permanent, silent unsubscribe.

Follow-ups they will ask

"A subscriber joins after 100 events have already been published — should it get them?" By this design, no — a subscriber only receives events published after it subscribes, which must be stated as a deliberate scope boundary, not discovered as a surprise. Supporting "replay from history" requires a fundamentally different architecture (a persisted, ordered log every subscriber reads from at its own pace, the way Kafka works) rather than an in-memory queue-per-subscriber — naming that this is a different guarantee requiring a different data structure, not a small addition, is the strong answer.

"Guarantee ordering: subscriber A must see events in the exact order they were published." A single BlockingQueue per subscriber already gives FIFO order for that one subscriber, since one worker thread drains it sequentially — the risk is if the design were changed to use a thread pool per subscriber instead of one dedicated worker thread, which would let events from the same subscriber's queue be processed out of order. Naming why the single-worker-thread choice specifically preserves ordering, not just that it happens to work, shows the property was understood.

"What happens if 10,000 subscribers register for one high-volume topic?" Ten thousand dedicated OS threads is the naive extension of this design and it doesn't scale — the fix is a bounded worker-thread pool shared across subscribers, with each subscriber's queue processed by whichever pool thread picks it up next, rather than one thread per subscriber. This trades strict ordering-per-subscriber for the (fixable, if a subscriber's queue is always processed by scheduling its next event only after its previous one completes) cost of thread-pool management, and naming that trade explicitly is worth more than assuming one-thread-per-subscriber scales indefinitely.

How to say it in the interview

"A naive publish() that calls each subscriber's callback in a for-loop on the publishing thread means one slow subscriber's callback makes every other subscriber, and the publisher itself, wait on it. Decoupling publish from delivery - handing each subscriber its own queue and worker - is the actual exercise; the registration bookkeeping is the easy half."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons