backend / bunstack

bunstack

A zero-dependency backend stack for Bun that so far consists of three core modules and twelve measured concurrency experiments, which became a 25-scene video.

2026 · research · local repository

load → serve: HTTP, 500 concurrentserve → atomic: critical sectionatomic → sqlite: sync read-modify-writeserve → errors: thrown HttpErrorserve → ids: primary keysload → procs: kernel-balanced connectionsserve → workers: postMessage(SharedArrayBuffer)Load generator (500fetches)Load generator (500 fetches) (client) Promise.all over fetchN processes on oneportN processes on one port (worker) reusePort: trueBun.serve handlerBun.serve handler (service) one JS threadatomic() / KeyedMutexatomic() / KeyedMutex (service) src/core/atomic.tsHttpError →ProblemDetailsHttpError → ProblemDetails (service) src/core/errors.tsULID / UUIDv7 idsULID / UUIDv7 ids (service) src/core/id.tsWorker threads +SharedArrayBufferWorker threads + SharedArrayBuffer (worker) real-race.tsbun:sqlite (sync,in-process)bun:sqlite (sync, in-process) (store) in-memoryHTTP, 500 concurrentcritical sectionsync read-modify-writethrown HttpErrorprimary keyskernel-balanced conne…postMessage(SharedArr… load → serve: HTTP, 500 concurrentserve → atomic: critical sectionatomic → sqlite: sync read-modify-writeserve → errors: thrown HttpErrorserve → ids: primary keysload → procs: kernel-balanced connectionsserve → workers: postMessage(SharedArrayBuffer)Load generator (500fetches)Load generator (500 fetches) (client) Promise.all over fetchN processes on oneportN processes on one port (worker) reusePort: trueBun.serve handlerBun.serve handler (service) one JS threadatomic() /KeyedMutexatomic() / KeyedMutex (service) src/core/atomic.tsHttpError →ProblemDetailsHttpError → ProblemDetails (service) src/core/errors.tsULID / UUIDv7 idsULID / UUIDv7 ids (service) src/core/id.tsWorker threads +SharedArrayBufferWorker threads + SharedArrayBuffer (worker) real-race.tsbun:sqlite (sync,in-process)bun:sqlite (sync, in-process) (store) in-memoryHTTP, 500 concurrentcritical sectionsync read-modify-writethrown HttpErrorprimary keyskernel-balanced conne…postMessage(SharedArr…
clientservicestoreworkerasync
  • [*]LanguagesTypeScript
  • [*]FrameworksBun.serve · bun:sqlite
  • [*]StoresSQLite (in-memory, experiments)
  • [*]AsyncBun Worker threads · SharedArrayBuffer + Atomics · SO_REUSEPORT multi-process
  • [*]InfraBun 1.x runtime

package.json describes bunstack as a complete backend stack written in nothing but Bun, with zero runtime dependencies. It declares 12 subpath exports: http, db, cache, ratelimit, auth, queue, observability, config, mail, schema, testing and the root. Only src/core has code: three files, 280 lines covering ULID and UUIDv7 ids, an HTTP error hierarchy that serialises to problem details, and two concurrency primitives. The other eleven module directories are empty, and the repository has no commits yet.

Most of the work is in experiments/: 12 TypeScript files, 319 lines, each a small benchmark against a real Bun.serve server and an in-memory bun:sqlite database. They test one question: when does a read-modify-write inside a request handler need a lock? I re-ran them on 2026-10-12. With 500 concurrent requests, a synchronous SQLite read and write lost nothing, and so did awaiting an already-resolved promise. Await Bun.sleep(0) lost 449 of 500 writes. A 1 ms simulated async driver lost 489.

The results set the design of the core. atomic() gets its type from that boundary: the callback must return T and never Promise<T>, so an await inside a critical section is a compile error rather than a lost write. KeyedMutex covers the case where the critical section really must await. Its docstring says plainly that it serialises within one process only, and that across processes the database has to arbitrate.

The same measurements narrate video/, a 25-scene explainer with an act-based script. The figures it quotes (58% of 800,000 shared-integer increments lost, about 91k wasted optimistic retries) match what the experiments print, within run-to-run noise.

How it holds up

Auth
None implemented: src/auth is declared in package.json exports but the directory is empty. The only auth-adjacent code is the Unauthorized error, which carries a WWW-Authenticate challenge header.
Data
No persistent schema. The experiments create single-table in-memory SQLite databases: a one-row counter, plus a 500,000-row items table used for the scan-versus-point-lookup test.
Consistency
Concurrency is the subject of the project. The core offers atomic(), a type-level ban on await inside a critical section, and KeyedMutex, a per-key promise chain that swallows a predecessor's rejection and deletes the map entry once its tail settles. ladder.ts compares read-modify-write, SET n=n+1 in SQL, and compare-and-swap with retry under 500 concurrent requests.
Failure
HttpError.toProblem hides the message and details of any error with status >= 500 (expose defaults to status < 500), so internals do not leak to clients. TooManyRequests and ServiceUnavailable set Retry-After. KeyedMutex keeps one failed task from poisoning the queue for its key.
Testing
No tests: test/ and examples/ are empty and there are no *.test.ts files. The 12 experiment scripts are runnable benchmarks that print pass/fail tables (e.g. 'correct' / 'WRONG'), not assertions.

Techniques

  1. Type-level ban on await in a critical sectionatomic<T>(fn: () => T extends Promise<unknown> ? never : T) won't type-check when the callback is async. The await that opens a lost-update window becomes a compile error, where otherwise it would be a production race.src/core/atomic.ts:28
  2. Per-key promise-chain mutex with self-cleaning mapKeyedMutex.run chains each task behind the previous one for the same key, runs the next task whether the previous one resolved or rejected, and deletes the map entry only if it is still the tail. Memory stays bounded and one failure does not stall the key.src/core/atomic.ts:37 src/core/atomic.ts:44 src/core/atomic.ts:46
  3. Measured lost-update ladderPuts 500 real concurrent HTTP requests against one counter and varies only the gap between read and write: none, a microtask, a macrotask, or 1 ms. A second script compares read-modify-write, relative SQL update and compare-and-swap with retry. On 2026-10-12: 0, 0, 449 and 489 writes lost; then 491 lost, 0 lost, and 0 lost at the cost of 92,851 retries.experiments/01-sync-atomicity/lost-update.ts:22 experiments/01-sync-atomicity/ladder.ts:12 experiments/01-sync-atomicity/ladder.ts:22
  4. Event-loop stall measurementMeasures what one synchronous 500k-row scan does to 200 concurrent indexed lookups, against an async wait of the same duration. p50 went from 9.6 ms to 48.0 ms with the sync scan and to 11.6 ms with the async wait: this is the cost of synchronous SQLite.experiments/01-sync-atomicity/stop-the-world.ts:27 experiments/01-sync-atomicity/stop-the-world.ts:51
  5. Time-sortable ids without dependenciesHand-rolled ULID (Crockford base32, 48-bit ms timestamp plus 80 random bits) and RFC 9562 UUIDv7 (version and variant bits set by hand), both with timestamp extraction, built on crypto.getRandomValues so nothing has to be installed.src/core/id.ts:12 src/core/id.ts:43
  6. Problem-details error hierarchy with safe exposureTyped HttpError subclasses carry status, code, details and headers (Allow, Retry-After, WWW-Authenticate). expose defaults to status < 500, so server-side faults reach the client as a generic message with no details.src/core/errors.ts:39 src/core/errors.ts:42 src/core/errors.ts:114

Counted

  • 0 (no commits yet)commits
    git status: 'No commits yet'
  • 280 across 3 filescore source LOC
    wc -l src/core/*.ts
  • 319 across 12 filesexperiment LOC
    wc -l experiments/*/*.ts
  • 12 (11 subpaths + root)declared subpath exports
    package.json exports
  • 1 of 12 (core)module directories with code
    find src -type f
  • 0runtime dependencies
    package.json (devDependencies only)
  • 0test files
    find . -name '*.test.ts' (excluding node_modules)
  • 449 / 500lost writes, 500 reqs, await Bun.sleep(0)
    bun run experiments/01-sync-atomicity/lost-update.ts (2026-10-12)
  • 92,851CAS retries to stay correct, 500 reqs
    bun run experiments/01-sync-atomicity/ladder.ts (2026-10-12)
  • 485,733 / 800,000 (60.7%)shared-int increments lost, 4 threads, no Atomics
    bun run experiments/02-what-is-concurrency/real-race.ts (2026-10-12)
  • 25video scenes
    video/script/scenes.json