Design a Pub/Sub Event Bus in Java
Fast recall
Quick revision
Core answer
Subscriber registry data structure, type-safe dispatch, async delivery model, failure isolation, and lifecycle management.
Synchronous in-thread delivery (one slow handler blocks all), strong references holding subscribers forever, or no per-handler error isolation.
Discuss back-pressure (bounded queue), ordering guarantees (per topic/per key), Disruptor's ring-buffer alternative for ultra-low-latency, and dead-event handling for unhandled types.
Interview-ready guide
Interview answer
The data structure
Map from event Class to a list of handlers. ConcurrentHashMap outer; CopyOnWriteArrayList for the inner list. Subscribe and unsubscribe are rare; publish is hot — COWAL gives lock-free reads (just a volatile array read) at the cost of expensive writes. Perfect read-mostly fit.
Dispatch is registry.get(event.getClass()) — O(1) lookup, then iterate handlers.
Hierarchy support — optional
If events extend a base type and parents should receive child events too, walk the class hierarchy on publish. Cache the type chain per event class. For maximum simplicity, exact-class match — cleaner semantics.
Async dispatch
Publisher returns immediately; handlers run on an ExecutorService. Why:
- Slow handlers don't block publisher
- Failure isolation natural — wrap each handler in try/catch in the executor task
- Handlers run concurrently
Trade-off: lose strict cross-handler ordering. Sync is simpler if handlers are fast and predictable (Spring's default).
Per-handler failure isolation
for (Handler<?> h : targets) {
executor.submit(() -> {
try { ((Handler<E>) h).accept(event); }
catch (Throwable t) { log.error(...); }
});
}One handler throwing doesn't affect siblings or the publisher.
Subscription lifecycle
Return a Subscription handle from subscribe(); caller calls cancel() to unsubscribe. Cleaner than passing the consumer back.
Memory safety: WeakReference to subscriber as a safety net for forgotten unsubscribes. But: lambdas capture enclosing instances, so explicit unsubscribe is still the contract. Spring's @PreDestroy / DisposableBean for managed beans.
Ordering and per-key ordering
Default async dispatch loses cross-event ordering. If per-handler order matters, use a per-handler single-threaded executor.
For per-key ordering (events with same key handled in order, different keys in parallel): hash key to one of N executors. Same key → same executor → in order. Same model as Kafka partitions, but in-process.
Backpressure
Critical: bounded queue. Unbounded executor queue = OOM under load.
- CallerRunsPolicy: publisher thread runs the task itself — natural backpressure
- AbortPolicy: drop and throw if dropping is acceptable
- Monitor queue depth; alert if sustained > threshold
LMAX Disruptor pattern: pre-allocated ring buffer with lock-free producers/consumers; ultra-low-latency for financial trading.
Dead events
Events with no subscribers: record to a dead-event queue. Catches forgotten subscriptions in dev; aggregate metrics in prod.
LLD
- EventBus facade with subscribe/unsubscribe/publish
- HandlerRegistry: type-indexed map operations
- Dispatcher interface: SyncDispatcher / AsyncDispatcher / DisruptorDispatcher — swappable
- DeadEventQueue
- Subscription returned for clean unsubscribe
Failure modes
- Handler exception — per-handler try/catch swallows + logs; optional poison-event queue
- Slow handler — bounded queue; per-handler executor isolates
- Sustained over-publish — backpressure via CallerRunsPolicy or drop-oldest
- Subscriber leak — WeakReference safety net + periodic sweep
- Re-entrant publish (handler publishes) — risky in sync; async breaks cycles
- Lost events on shutdown — graceful drain with timeout; for durability use Kafka
Production choices
- Guava EventBus — simple in-process
- Spring ApplicationEventPublisher — integrated with bean lifecycle
- Vert.x EventBus — supports clustered (cross-process)
- LMAX Disruptor — ultra-low-latency ring buffer
Recommendation
For most in-process use: Map
Design a Pub/Sub Event Bus in Java
Direct Answer
Index subscribers by Class<? extends Event> so dispatch is Map<Class, List<Handler>>. Use CopyOnWriteArrayList for handler lists (read-mostly, rare subscribe) and an ExecutorService for async delivery. Hold subscribers via WeakReference to avoid leaking memory when callers forget to unsubscribe. Isolate failures (one handler throwing must not kill others) and never block the publish thread by default. The popular references: Guava's EventBus for simple in-process, Spring's ApplicationEventPublisher, Vert.x EventBus for clustered, LMAX Disruptor for ultra-low-latency.
Common Mistakes & Pitfalls
Synchronous in-thread delivery (one slow handler blocks all), strong references holding subscribers forever, or no per-handler error isolation.
Check your understanding
Answer these without looking back at the article.
- How would you support typed events with hierarchical inheritance (handler for parent receives child events)?
- Extend the bus to support cluster-wide pub/sub across JVM instances — what changes?
- Design a backpressure strategy for a publisher that's 10x faster than consumers can keep up with.
- Walk through how Disruptor's ring buffer beats the executor-pool approach for low-latency event delivery.
Related interview prep categories
2Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.