My stack The stack · Bun

Bun

I wrote a backend with an empty dependency block. The runtime is the framework.

0entries in dependencies — the whole API is Bun’s own surface
1executable out of bun build --compile, database beside it
7route modules: auth, devices, lending, preferences, catalogue, search, events
01The question it answers

Tether’s API was not written dependency-free as a stunt. It started as a question I could not answer honestly: how much of a framework do I actually need? Express gives you routing, body parsing, middleware. Bun gives you an HTTP server, a synchronous SQLite driver, and a password hasher — in the runtime, maintained by the runtime.

So I wrote the router. It is small. And the bet was that a primitive maintained by the people who maintain the runtime ages better than a package maintained by someone who has moved on.

02What replaced each package Four primitives, and the package each one stands in for.
The substitution

Every line of the server is something the runtime already shipped.

Bun.serveHTTP and routing
bun:sqlitesynchronous, in-process
Bun.passwordargon2id, off the loop
bun build --compileone executable

The interesting one is bun:sqlite. It is synchronous, which in Node would be a mistake — a slow query blocks every other request on the event loop. In a single-tenant API where the database is a file on the same disk, a query is measured in microseconds and the synchronous call is faster than the async ceremony around it. But being right on average is not the same as being safe, which is what section 04 is about.

03What it commits you to
You own the router

No framework means no framework’s middleware, no framework’s error handling, and no framework’s twenty-year history of edge cases. That is the cost, paid once, in code I can read in an afternoon.

You own the migrations

Applied at boot and logged by name, so starting the server tells you what changed. There is no migration tool to configure and none to be surprised by.

You own the metrics

Nobody instruments your server for you. /metrics reports resident memory, open connections and the number in section 04.

Deployment is a file copy

bun build --compile produces an executable. No Node on the host, no node_modules to install, no lockfile to resolve differently in production than it did on my machine.

04The number that makes it safe Event-loop lag is the one measurement a synchronous database cannot hide from.
The safety net

A synchronous query blocks everyone. So measure the blocking, not the query.

timer set for 500msthe baseline
it fires latesomething held the loop
maxEventLoopLagMsserved on /metrics

A 500ms interval that fires at 512ms means something occupied the thread for 12ms. That is the honest signal: not how fast a query was in isolation, but whether it stopped everybody else. Reading query timings would have told me the happy path was fine; reading loop lag tells me when it was not. The same file also reports open connections, on the principle that a connection you cannot see is a connection you leak.

A connection you cannot see is a connection you leak.

apps/api/src/server.ts, on why /metrics exists at all
05What it is not

Zero dependencies is a bet, not a virtue.

  • It does not scale past one machinebun:sqlite is a file. That is the right answer for Tether and the wrong answer the moment two servers need the same rows — at which point the database changes and most of this page stops applying.
  • It is not a general recommendationI would not write a multi-tenant billing system this way. The bet pays when the surface is small and the data is one machine’s.
  • Custody settles on a timer tooTransfers have a veto window, and it closes on a schedule rather than only when someone reads it — because a window that only closes when observed is not a window.
06The repository
The case study

Tether — apps/api

The API is seven route modules over a core of router, auth, atomic custody, ids and events. Read src/server.ts first: it is short, and it names every primitive the rest of the service is built from.

The framework I did not install could not break.

That is the whole argument, and it is a narrow one. It holds for a small surface on one machine, and it stops holding the moment either of those changes.