Java · 1.3.1
The original, embedded in service-bus / 1M5. The only port with guaranteed-delivery persistence, datatype channels, and a pull model.
SEDA bus · Technical deep dive
SEDA bus is a small, broker-less, staged message bus — work decomposed into named stages connected by bounded queues, each with its own admission control — implemented from one shared design in seven languages: Java, Rust, Python, TypeScript, C++, C#, and Go. This page is the design in full, the seven implementations and how each necessarily diverges from it, and the results of benchmarking all seven against each other and against themselves under load.
This is reference material, not a pitch — SEDA bus is a personal, non-commercial project. See the short version on the research page for the framing; everything below is the depth that didn't fit there.
What it is
Work is decomposed into stages. A stage is a named channel holding a bounded queue and a list of consumers. Producers publish envelopes; they never call consumers directly — the queue between them is the decoupling point and the place where load is conditioned.
One shared worker pool drains every stage. Each stage has its own concurrency limit — the number of envelopes it may process at once — so no single stage can monopolize the pool. This is the one structural departure from the original 2001 SEDA paper (Welsh, Culler, Brewer, SEDA: An Architecture for Well-Conditioned, Scalable Internet Services, SOSP 2001), which gave every stage its own dedicated pool. Welsh's own retrospective concluded the per-stage-pool split was usually a mistake, and all seven implementations avoid it — six via one shared pool, Go via goroutines bounded by a shared semaphore instead of a pool at all.
Everything is in-process. There is no broker, no network hop, no persistence broker — the only external moving part is the worker pool. There is no polling loop: publishing schedules a self-rescheduling drain task, gated by a per-stage concurrency permit. A drain task processes a bounded batch (16–64 envelopes depending on the port), releases its permit, and re-schedules itself if the queue is non-empty.
The envelope & routing slip
An envelope is the unit of work: a stable id, a to (the channel it is currently
headed for), optional sender, headers, a payload, an attempts counter for the current hop, and
a routing slip — an ordered list of further stages to visit. A consumer
advances an envelope by leaving routes on the slip and returning; when a stage acks an
envelope, the bus pops the slip's next hop and re-publishes, or — if the itinerary is done
— fires the producer's completion callback.
Delivery within a stage is either point-to-point (one consumer per envelope, chosen round-robin) or pub/sub (every consumer handles every envelope; the stage acks only if all of them ack). A consumer returns a boolean — ack or nack — and a thrown exception, panic, or rejected promise is caught and treated as a nack, so a misbehaving consumer never takes down a worker.
A nacked envelope with attempts remaining is retried immediately at the head of its own queue; once attempts are exhausted, or a stage has no consumers, the envelope is dead-lettered to a registered dead-letter channel and its completion callback is dropped.
Admission control & back-pressure
publish does when a stage's queue is full.| Policy | Behavior when the queue is full |
|---|---|
Block | the producer waits (up to the publish timeout) for room |
Reject | publish returns false immediately |
DropNewest | silently discard the envelope being offered |
DropOldest | evict the head of the queue, enqueue the new envelope |
publish returns a boolean (or a promise of one) — whether the envelope was
accepted. Every port defaults to Block except Java, which defaults to
Reject and makes the policy configurable per channel. Per stage, the bus tracks plain
counters — depth, enqueued, delivered, nacked, dropped, dead-lettered — a snapshot
available via stats(). SEDA's point is that you measure stages so you can
tune them; none of the seven ports builds the tuner that would consume those numbers automatically
— see “What none of them implement” below.
The seven implementations
The design above is common. What differs is dictated by each language's concurrency model, type system, and ecosystem — and that divergence is the actual point of building it seven times. Each page below covers how that port implements the shared design, and what the benchmark comparison found once its numbers are read against those design choices.
The original, embedded in service-bus / 1M5. The only port with guaranteed-delivery persistence, datatype channels, and a pull model.
Zero-dependency-minded, a real hand-rolled shared OS-thread pool, and the highest single-stage sustained throughput of any port.
Built to exercise free-threaded CPython (PEP 703) — benchmarked in both a free-threaded and a GIL build so the difference is measured, not assumed.
The odd one out: the event loop is the “shared pool,” with an optional Worker-thread transport for CPU-bound stages.
Header-only C++20, following Rust's concurrency model almost mechanically — and the one port whose 8-producer throughput is lower than its 1-producer number.
Follows Java's model instead of Rust's — the shared, process-wide .NET ThreadPool and a real SemaphoreSlim, not a hand-rolled pool.
Needs no worker pool at all — a bare goroutine per drain, bounded by a buffered-channel semaphore. The only port with a clean -race run.
Benchmark snapshot
seda-bus-compare
measures each stage's own sustained throughput under a real, bounded (1024) queue with
Block back-pressure — cap1 is one producer, cap8 is up
to eight producers sharing one channel. Full detail, per language, is on each implementation's own
page; the numbers below are every port side by side. Run date 2026-09-13, on a verified-quiet
Docker host — see
the full RESULTS.md
for methodology, every anomaly explained individually, and the four real bugs this benchmark found
and fixed in the libraries it was measuring.
| Implementation | cap1 sustained |
cap8 sustained |
|---|---|---|
| Rust | 679,363 | 456,160 |
| Java | 648,341 | 444,318 |
| Go | 179,706 | 399,558 |
| C# | 239,952 | 340,295 |
| TypeScript | 203,898 | 219,670 |
| C++ | 179,119 | 90,980 |
| Python 3.14t (free-threaded) | 100,868 | 25,366 |
| Python 3.13 (GIL) | 41,126 | 16,648 |
Read this as a snapshot, not a leaderboard — three trials on one host, and every implementation
tracks its own sustained ceiling closely at 0.5×/1.0× load and drops together at a
deliberate 1.5× overload, as it must. The one number here that looks structurally wrong
— C++'s cap8 lower than its cap1 — is real, not a
measurement artifact, and is explained on the C++
page. Each implementation page below covers its own numbers, its own anomalies, and how its
design choices explain them.
What none of them implement
SEDA's original design included a controller that watched per-stage latency and queue depth at runtime and re-tuned each stage's thread allocation, shedding load automatically. In all seven implementations, every setting is static configuration — you pick each stage's capacity and concurrency up front.
That's deliberate, not an oversight: no major broker (Kafka, NATS, RabbitMQ, Pulsar) ships a full closed-loop resizer either, naive adaptive thread-pool sizing is a documented oscillation risk, and Matt Welsh's own 2010 retrospective on SEDA suggested grouping stages under shared pools instead of a per-stage controller — which is what six of these seven ports already do. What's worth building instead is smaller and lower-risk: a stateless adaptive admission/shedding policy, and a runtime-tunable pool size an external orchestrator can act on — adaptation staying outside the library, where the policy choice belongs.
The full design document, including the correctness suite and potential upgrades →
Source
Each language's implementation notes and correctness suite live with its own repository. The shared design document and the cross-language benchmark are separate repositories that reference all seven.