In-Process Pub-Sub Bus
An in-memory pub-sub: publishers send to topics, subscribers receive matching messages. Decisions: per-subscriber bounded queue (slow consumer doesn't block fast); ordered or fan-out; sync or async delivery. Trades latency for backpressure.
The problem
Build an in-memory pub-sub: publishers send messages to topics; subscribers register for topics and receive matching messages. Used in: Spring's ApplicationEventPublisher, GUI event buses, in-process plugin systems.
Constraints:
- Many publishers, many subscribers.
- Slow subscriber must not block publisher.
- Dynamic subscribe/unsubscribe.
- Thread-safe under concurrent publish + subscribe.
What this tests Composing ConcurrentHashMap (topics → subscribers) + bounded queues (per-subscriber buffering) + worker threads (subscriber drain) + atomic counters (drop tracking). Four primitives, one design.
The architectural decision
Per-subscriber queue pattern Don't make publishers iterate-and-deliver synchronously, slow handler blocks publisher.
Instead: each subscriber owns a queue + worker thread. Publish = enqueue to each subscriber's queue. Subscriber's worker drains and runs the handler. Slow handler only delays its own queue.
The queue capacity is the tuning knob: small (drop fast on backpressure) vs large (absorb bursts but use memory).
Slow-consumer policy
This is the most contested design choice. Three options:
| Policy | Semantics | Use when |
|---|---|---|
| Drop on full | Publisher succeeds; slow consumer loses events | Telemetry, metrics, optional notifications |
| Block on full | Publisher waits; backpressure flows up | Orders, financial events, anything that can't be lost |
| Block with timeout | Publisher waits N ms, then drops | Most production buses, middle ground |
What kills naive implementations "Synchronous handler dispatch", publisher calls each subscriber inline. One slow handler delays every subscriber + the publisher. This is the #1 production-incident shape: a logging subscriber stalled the order-processing publisher.
When NOT to roll a custom one
For most use cases:
- Spring
ApplicationEventPublisher, Spring-aware. - Guava EventBus, simple, in-process.
- Reactor / RxJava, full reactive streams with backpressure.
- Kafka / NATS / Redis Streams, when durability or cross-service delivery is required.
Roll a custom bus only after measuring the alternatives and finding they don't fit. Most "in-process pub-sub" interview answers should cite the right library and discuss its tradeoffs, not build from scratch.
Implementations
Each subscriber owns a dedicated thread that drains its own queue. Publisher iterates topic's subscribers and offers to each queue. Drop-on-full prevents slow-consumer backpressure.
1 import java.util.concurrent.*;
2 import java.util.concurrent.atomic.AtomicLong;
3
4 class EventBus<T> {
5 private final ConcurrentHashMap<String, List<Subscriber<T>>> topics = new ConcurrentHashMap<>();
6 private final AtomicLong dropped = new AtomicLong();
7
8 public Subscription subscribe(String topic, Consumer<T> handler) {
9 Subscriber<T> sub = new Subscriber<>(handler);
10 topics.computeIfAbsent(topic, k -> new CopyOnWriteArrayList<>()).add(sub);
11 return () -> topics.get(topic).remove(sub);
12 }
13
14 public void publish(String topic, T event) {
15 List<Subscriber<T>> subs = topics.get(topic);
16 if (subs == null) return;
17 for (Subscriber<T> sub : subs) {
18 if (!sub.queue.offer(event)) dropped.incrementAndGet();
19 }
20 }
21
22 static class Subscriber<T> {
23 final BlockingQueue<T> queue = new ArrayBlockingQueue<>(1024);
24 final Thread worker;
25
26 Subscriber(Consumer<T> handler) {
27 worker = new Thread(() -> {
28 while (!Thread.currentThread().isInterrupted()) {
29 try { handler.accept(queue.take()); }
30 catch (InterruptedException e) { return; }
31 catch (Exception e) { /* log; don't kill the worker */ }
32 }
33 });
34 worker.setDaemon(true);
35 worker.start();
36 }
37 }
38
39 interface Subscription { void unsubscribe(); }
40 }Key points
- •Topics → subscriber list; publish iterates and delivers (or queues per subscriber)
- •Each subscriber has bounded queue → slow consumer doesn't backpressure publisher
- •Drop-on-full vs block-on-full, slow-consumer policy is THE design decision
- •Concurrent map for topic→subscribers (CHM, sync.Map, dict + RLock)
- •Used by Spring Events, Akka Streams, in-process EventBus libraries