Bun

Diminuendo runs on Bun — not as a fashion choice, but because Bun provides three load-bearing capabilities that would each require a separate dependency (and a separate failure mode) in Node.js: a native WebSocket server with built-in pub/sub, native SQLite with prepared statements and WAL mode, and Web Workers for offloading I/O. These are not nice-to-haves. They are the infrastructure on which the gateway’s performance and operational simplicity directly depend.

Why Bun over Node.js

The decision to use Bun over Node.js was not an optimization for throughput alone — it was an optimization for architectural surface area. Each capability that Bun provides natively is one fewer external dependency to version, configure, monitor, and debug in production.
CapabilityNode.jsBunArchitectural Impact
WebSocket serverws package, no pub/subBun.serve() with topic-based pub/subEliminates external message broker for single-instance
SQLitebetter-sqlite3 (native addon)bun:sqlite (built-in)No native compilation, no node-gyp, no platform-specific binaries
Startup time500ms-2s for typical services< 200msRapid restart cycles, minimal downtime during deployment
Web Workersworker_threadsNative Worker with postMessageI/O offloading for SQLite without third-party threading libraries
The net effect: Diminuendo’s package.json contains zero infrastructure dependencies. There is no ws, no better-sqlite3, no ioredis, no pg. The runtime is the infrastructure.

Bun.serve: One Port, Two Protocols

Bun.serve() handles both HTTP and WebSocket traffic on a single port. There is no separate WebSocket server to configure, no port allocation to manage, no reverse proxy to wire up for protocol switching.
const server = Bun.serve({
  port: config.port,
  fetch(req, server) {
    // HTTP path: health checks, REST API
    if (url.pathname === "/ws") {
      const upgraded = server.upgrade(req, { data: wsData })
      if (upgraded) return undefined
      return new Response("WebSocket upgrade failed", { status: 400 })
    }
    return handleHttpRequest(req, services)
  },
  websocket: {
    open(ws) { /* send welcome + connected */ },
    message(ws, data) { /* validate, route, execute */ },
    close(ws, code, reason) { /* cleanup */ },
    perMessageDeflate: true,
  },
})
This consolidation is not merely convenient. It means the health check endpoint and the WebSocket handler share the same process, the same memory space, and the same view of application state. A health check that reports “healthy” is guaranteed to be running in the same process that is serving WebSocket connections — there is no load balancer probing a sidecar while the main process is wedged.

WebSocket Pub/Sub

Bun’s WebSocket implementation includes a topic-based publish/subscribe system that operates entirely in-process. There is no external broker, no serialization to Redis, no network hop between publish and delivery.

Topic Naming

Diminuendo uses two topic namespaces:
  • session:{sessionId} — all events for a specific session. Every client that has joined a session subscribes to its topic.
  • tenant:{tenantId}:sessions — tenant-wide notifications. Session creation, deletion, archival, and rename events are broadcast here so that every authenticated client in the tenant can update its session list.
// Subscribing a client to a session topic
ws.subscribe(`session:${sessionId}`)

// Publishing an event to all subscribers
server.publish(`session:${sessionId}`, JSON.stringify(event))

The Broadcaster Abstraction

The Broadcaster service wraps Bun’s raw pub/sub in an Effect Layer, providing typed methods for session events, tenant events, and shutdown broadcasts. This abstraction serves two purposes: it makes pub/sub testable (tests provide a mock Broadcaster) and it prepares for future multi-instance deployments where the pub/sub layer would need to bridge across processes. The Broadcaster tracks every topic that has received at least one publish. On shutdown, it iterates this set and publishes a server_shutdown event to each, ensuring every connected client receives orderly notification before the process exits.
Bun’s WebSocket pub/sub is per-process. For multi-instance deployments where a session’s subscribers might span gateway instances, a shared pub/sub layer (Redis Streams, NATS, or similar) would be needed. The Broadcaster abstraction makes this swap a Layer replacement, not a rewrite.

bun:sqlite

Bun ships with a built-in SQLite binding (bun:sqlite) that operates synchronously and in-process. There is no network round-trip, no connection pool, no serialization overhead, no external process to manage. A query executes as a function call — the same address space, the same event loop tick.

Key Characteristics

  • Synchronous APIdb.query("SELECT ...").all() returns rows immediately. No await, no callback, no promise. In the context of Web Workers (where blocking is acceptable), this is the optimal execution model.
  • Prepared statementsdb.query(sql) compiles the SQL once and returns a reusable Statement object. Diminuendo caches these in a WeakMap keyed by database handle, so hot-path queries (event inserts, message fetches) never recompile.
  • WAL mode — Write-Ahead Logging allows concurrent readers while a single writer commits transactions. The two-worker architecture exploits this: the reader worker never blocks the writer, and the writer never blocks readers.
  • Zero serialization — rows are returned as JavaScript objects directly from the native binding. There is no JSON parse step, no ORM hydration, no ResultSet wrapper. The data arrives in the shape the caller needs.

Performance Impact

The in-process nature of bun:sqlite eliminates the dominant cost in traditional database architectures: network latency. A PostgreSQL query over TCP adds 1-5ms of latency per round-trip (more under load, more in cloud environments with network virtualization). An in-process SQLite query adds microseconds. For a gateway that executes multiple database operations per client message, this difference compounds:
Operationbun:sqlitePostgreSQL (TCP)Delta
Session creation~0.3ms~10-20ms30-60x
Event insert (batched)~0.05ms per row~2-5ms per row40-100x
History query (100 rows)~0.5ms~5-15ms10-30x
These are not synthetic benchmarks — they reflect the measured difference between Diminuendo and a Next.js-based gateway backed by PostgreSQL. See the benchmark page for full results.

Web Workers for SQLite I/O

On the main thread, Bun’s event loop handles WebSocket I/O, JSON parsing, and Effect fiber scheduling. Synchronous SQLite writes on the main thread would block event delivery to all connected clients for the duration of the transaction — an unacceptable trade-off for a real-time streaming gateway. Diminuendo offloads all SQLite I/O to two dedicated Bun Web Workers:
  • The writer worker batches incoming commands and flushes on a 50ms timer or at 100 commands, whichever comes first. Writes never block the main thread.
  • The reader worker handles SELECT queries on a separate thread. WAL mode ensures readers never block the writer.
Communication between the main thread and workers uses postMessage with structured cloning. The WorkerManager Effect Layer abstracts this boundary entirely — consumers call typed methods (write(), readHistory(), flush()) without awareness that structured messages cross thread boundaries. For the full details of the batching strategy, LRU cache sizing, shutdown protocol, and prepared statement management, see Multi-Worker SQLite.

Trade-offs

Every runtime choice carries obligations. Bun’s are worth acknowledging:
Bun’s npm compatibility is high but not perfect. Some packages with native addons do not compile cleanly. In practice, Diminuendo’s zero-external-dependency architecture sidesteps this issue entirely — the gateway does not use ws, better-sqlite3, or any other native addon. But teams extending the gateway with third-party packages should verify Bun compatibility.
Node.js has been subjected to over a decade of adversarial workloads at planetary scale. Bun has not. Edge cases in memory management, garbage collection under pressure, and long-running process stability are less thoroughly explored. Diminuendo mitigates this with bounded resource caches (LRU eviction), periodic cleanup of rate limiter state, and a force-exit timer on shutdown.
Bun’s Web Workers differ from Node.js worker_threads in subtle ways — particularly around SharedArrayBuffer support and message port lifecycle. The multi-worker SQLite architecture was designed with Bun-specific semantics in mind. Porting to Node.js would require adjustments to the worker spawn and communication patterns.

Performance Summary

Benchmarks against a Next.js-based gateway (Crescendo) connecting to the same Podium and Ensemble backends demonstrate the compound effect of Bun’s native primitives:
MetricDiminuendoCrescendoSpeedup
Health check p500.6ms5.0ms8.4x
Health check RPS10,39029135.7x
Connection + auth p500.4ms5.5ms15.7x
Session creation p500.6ms17.7ms27.6x
Session creation stddev0.1ms8.9ms89x less variance
The speedups are not Bun-vs-Node micro-benchmark numbers. They are the measured difference in end-to-end gateway overhead: the time between a client sending a message and the gateway completing the operation. The dominant contributors are in-process SQLite (no network hop), native WebSocket pub/sub (no Redis serialization), and Bun’s HTTP server (no Next.js middleware stack). For the full benchmark methodology and raw numbers, see Performance Benchmark.