SEDA bus · Technical deep dive

One staged message-bus design, built seven times over.

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

Stages, bounded queues, and one shared worker pool.

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

A pipeline is an itinerary carried by the message, not fixed by the caller.

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

What publish does when a stage's queue is full.

Policy Behavior when the queue is full
Blockthe producer waits (up to the publish timeout) for room
Rejectpublish returns false immediately
DropNewestsilently discard the envelope being offered
DropOldestevict 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

Same design, seven concurrency models.

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.

Java · 1.3.1

The original, embedded in service-bus / 1M5. The only port with guaranteed-delivery persistence, datatype channels, and a pull model.

Read the Java implementation

Rust · 0.4.0

Zero-dependency-minded, a real hand-rolled shared OS-thread pool, and the highest single-stage sustained throughput of any port.

Read the Rust implementation

Python · 0.2.0

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.

Read the Python implementation

TypeScript · 0.2.0

The odd one out: the event loop is the “shared pool,” with an optional Worker-thread transport for CPU-bound stages.

Read the TypeScript implementation

C++ · 0.1.0

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.

Read the C++ implementation

C# · 0.1.0

Follows Java's model instead of Rust's — the shared, process-wide .NET ThreadPool and a real SemaphoreSlim, not a hand-rolled pool.

Read the C# implementation

Go · 0.1.0

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.

Read the Go implementation

Benchmark snapshot

A real bounded queue, real back-pressure, swept load.

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.

Sustained throughput under real, bounded back-pressure (envelopes/sec)
Implementation cap1 sustained cap8 sustained
Rust679,363456,160
Java648,341444,318
Go179,706399,558
C#239,952340,295
TypeScript203,898219,670
C++179,11990,980
Python 3.14t (free-threaded)100,86825,366
Python 3.13 (GIL)41,12616,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

The adaptive controller.

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

Seven repositories, one design document, one benchmark.

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.