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.
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
class EventBus<T> {
private final ConcurrentHashMap<String, List<Subscriber<T>>> topics = new ConcurrentHashMap<>();
private final AtomicLong dropped = new AtomicLong();
public Subscription subscribe(String topic, Consumer<T> handler) {
Subscriber<T> sub = new Subscriber<>(handler);
topics.computeIfAbsent(topic, k -> new CopyOnWriteArrayList<>()).add(sub);
return () -> topics.get(topic).remove(sub);
}
public void publish(String topic, T event) {
List<Subscriber<T>> subs = topics.get(topic);
if (subs == null) return;
for (Subscriber<T> sub : subs) {
if (!sub.queue.offer(event)) dropped.incrementAndGet();
}
}
static class Subscriber<T> {
final BlockingQueue<T> queue = new ArrayBlockingQueue<>(1024);
final Thread worker;
Subscriber(Consumer<T> handler) {
worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try { handler.accept(queue.take()); }
catch (InterruptedException e) { return; }
catch (Exception e) { /* log; don't kill the worker */ }
}
});
worker.setDaemon(true);
worker.start();
}
}
interface Subscription { void unsubscribe(); }
}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