DEV Community

Dan Stephenson
Dan Stephenson

Posted on

Flight and Scribe on Bun: method, numbers, and where Node still wins

On one pinned CPU core, Flight's Bun runtime served 61,110 requests per second on a hello route. Its Node runtime, at the same commit, served 19,651. Scribe served 21,349 reads per second against 4,998. Flight went from process start to first response in 47 ms instead of 1.18 s.

Two caveats up front, because they change how you should read those numbers:

  1. These are micro-benchmarks (one hello route for Flight, one notes schema for Scribe). They are not application benchmarks.
  2. On 4 cores, a 4-worker Node cluster served 80.7k req/s on Flight's hello route, against 53.8k for one Bun process. The single-core wins do not carry over to a cluster.

This post covers what changed, how we measured, and how to reproduce it. Flight is a Koa-based app server for component-shaped Vue and React apps with Vite. Scribe is a schema-driven data service on Postgres with version history and a Redis schema cache. Both are open source, and both now ship a Bun binary next to the unchanged Node one: flight-bun and scribe-bun.

What was compared

The same commit of each repo. In each case the Node binary is the original runtime path and the Bun binary is the port.

Project Commit Node side Bun side
Flight 3.0.0 b0d8689 flight: Koa on node:cluster, dist/flight.js built by tsc, *.backend.ts loaded through tsx (the loader the README names). A ts-node variant also ran. flight-bun: Bun.serve, src/bun/server.ts run directly as TypeScript
Scribe 1.3.0 9a0239f scribe: Express on node:cluster, dist/scribe.cli.js scribe-bun: Bun.serve transport over the same src/core.ts

Method

Hardware and versions. An 8-vCPU Intel Xeon VM with 15 GB RAM, Debian 13, kernel 6.12. It is a shared box with other processes running, so expect noise. Node 20.19.2, Bun 1.4.2, PostgreSQL 17.11, Redis 8.0.2, wrk 4.1.0, hyperfine 1.19.0.

Pinning. Single-core runs: server on core 1, Postgres and Redis on cores 2-3, wrk on cores 4-7 (-t4 -c100, and -c50 for Scribe writes). Four-core runs: server on cores 0-3, Postgres and Redis on 4-5, wrk on 6-7 for Scribe and on 4-7 for Flight.

Holding Node to one worker. Flight has a knob: FLIGHT_MAX_WORKERS=1. Scribe does not, so the harness preloads a tiny script that makes os.cpus() report one CPU, which makes node:cluster fork one worker. The primary process still runs, as it does in production:

// bench/cpus1.cjs
const os = require('os'); const one = os.cpus()[0]; os.cpus = () => [one];
Enter fullscreen mode Exit fullscreen mode

Runs. Every throughput row is a 10 s warmup followed by 3 x 20 s runs, and we report the mean. No non-2xx responses or socket errors appeared in any run. Memory is RSS of the whole server process tree (Node: primary plus workers), taken at idle after one request and again after load. Startup is the time from exec to the first HTTP 200 on a real route, measured with hyperfine (2 warmups, 15 runs).

Workloads. Flight: GET /api/hello returning {"message":"Hello from Flight!"}, in three variants: (a) a Node/Koa router.routes() backend, (b) the same Koa file on flight-bun through its compat layer, (c) a Bun-native route-map backend. Scribe: reads are GET /notes/1. Writes are POST /notes, which runs the shipped create path (two CREATE TABLE IF NOT EXISTS, an ALTER TABLE ... ADD COLUMN IF NOT EXISTS, the insert, and a history insert). Writes were run twice, the second time with tables truncated and the order reversed. Results matched.

Middleware is not identical across the two sides. Each runtime used its own shipped default for a non-dev, non-production mode. Node ran koa-logger (it cannot be turned off, output went to /dev/null), session, CORS and bodyparser. Bun ran security headers, CORS and compression, with no request logging. Neither side rate-limited. That is a real difference in work per request, and we are not claiming it is zero.

Results on one core

Test Node Bun Change
Flight req/s, Bun-native route 19,651 (tsx) / 19,342 (ts-node) 61,110 3.1x
Flight req/s, unchanged Koa route 19,651 38,826 2.0x
Flight p50 / p99, native 4.6-5.0 ms / 10-25 ms 1.5-1.6 ms / 2.9-3.0 ms
Flight p99, Koa on Bun 10-25 ms 6.3-7.2 ms
Flight idle RSS 218 MB (primary + 1 worker, tsx) 27 MB native / 47 MB Koa about 8x less
Scribe reads req/s 4,998 21,349 4.3x
Scribe read p50 / p99 17-21 ms / 52-65 ms 3.9-4.2 ms / 12.2-12.5 ms
Scribe writes req/s 1,273 2,327 1.8x
Scribe idle RSS 258 MB (primary + 1 worker) 68 MB about 3.8x less

Startup to first 200 (hyperfine, 15 runs): Flight native 1.182 s (tsx) vs 47 ms +/- 10; Flight with Koa routes on Bun 123 ms +/- 16; Scribe 1.210 s (1 worker) vs 319 ms +/- 34. The Node side includes loading TypeScript through tsx at startup, so part of that gap is the loader, not the runtime.

Where Node still wins: 4 cores

Test Node cluster, 4 workers Bun, one process
Flight hello route req/s 80,740 53,835 native / 41,526 Koa
Flight RSS (idle to after load) 550 to 578 MB 28 to 59 MB
Scribe reads req/s 21,455 22,768 (a tie; Postgres on 2 cores is likely the wall)
Scribe RSS (idle to after load) 641 to 700 MB 69 to 105 MB

The shipped Bun runtimes are one process and do not use four cores the way a four-worker cluster does. On Bun today you scale with replicas. We have not yet measured several Bun processes behind reusePort against a Node cluster on the same core count.

What changed in Flight

Bun.serve with a native route table

Instead of passing every request through a Koa middleware chain, flight-bun hands Bun.serve a route table built at startup. A Bun-native *.backend.ts exports an object keyed by path:

// hello.backend.ts (Bun-native contract)
export default {
    '/api/hello': { GET: () => Response.json({ message: 'Hello from Flight!' }) }
}
Enter fullscreen mode Exit fullscreen mode

Discovery scans for *.backend.ts and *.backend.js with Bun.Glob, imports each file, and sorts it by the shape of its default export: an object whose keys all start with / is a route fragment, anything else goes down the Koa path (below). The server is then started with the route table plus a fetch fallback:

server = Bun.serve({
    port: listen.port ?? cfg.port,
    hostname: listen.hostname ?? cfg.hostname,
    routes: app.routes,   // native routes, each wrapped in the middleware chain
    fetch: app.fetch,     // fallback: Koa-compat dispatch, then the SPA
    error: app.error
})
Enter fullscreen mode Exit fullscreen mode

One detail worth knowing: when CORS is on, OPTIONS has to be registered per path, because Bun answers 405 for an unregistered method before any of your middleware runs.

The Koa compat layer

We did not want anyone stuck mid-migration, so an existing export default router.routes() file still boots. The compat layer stands up a private Koa app, adds koa-bodyparser, mounts the discovered middleware, and calls app.callback() with a Node-style request and a mock response built from the Fetch Request:

function callbackToFetch(callback: (req: Readable, res: MockResponse) => void) {
    return async function dispatch(request: Request): Promise<Response | null> {
        const body = request.method === 'GET' || request.method === 'HEAD'
            ? Buffer.alloc(0)
            : Buffer.from(await request.arrayBuffer())
        const req = requestFromFetch(request, body)   // a Readable with method, url, headers
        const res = new MockResponse()                // buffers status, headers, chunks

        await new Promise<void>((resolve, reject) => {
            res.on('finish', () => resolve())
            res.on('error', reject)
            try { callback(req, res) } catch (err) { reject(err) }
        })

        if (!res.flightMatched) return null           // no Koa route matched: fall through to the SPA
        return res.toResponse()
    }
}
Enter fullscreen mode Exit fullscreen mode

res.flightMatched is set from ctx.matched, so an unmatched path returns null and the request falls through to static files. This path is still 2.0x the Node runtime on our test (38,826 req/s). Rewriting the route as a plain (req) => Response handler takes it to 3.1x.

Middleware rewrite

The edge middleware (logging, security headers, CORS, gzip, rate limiting, sessions, response cache) was rewritten against Request and Response with no Koa packages on that path. The composition is a small onion where index 0 is outermost and a middleware may return without calling next:

export function compose(middlewares: Middleware[]): (req: Request, core: Handler) => Promise<Response> {
    return function run(req, core) {
        const dispatch = (index: number): Promise<Response> => {
            const middleware = middlewares[index]
            if (!middleware) return Promise.resolve(core(req))
            return Promise.resolve(middleware(req, () => dispatch(index + 1)))
        }
        return dispatch(0)
    }
}
Enter fullscreen mode Exit fullscreen mode

Sessions and the response cache use Redis when redisUrl is set and fall back to an in-memory store otherwise, so Redis is optional now. We did not run an ablation, so we cannot say how much of the gain comes from the route table, the middleware rewrite, or removing the loader. The two sides also run different default middleware, as noted above.

No build step

Bun loads .backend.ts directly, so production needs neither tsc nor a TypeScript loader. That is part of the startup gap: on the Node side, tsc took 3.5 s (Flight) and 4.4 s (Scribe) before anything ran, and the tsx loader is in the startup path.

What changed in Scribe

We split Scribe's core logic from its Express transport (src/core.ts), then added scribe-bun, a thin Bun.serve transport over that core with a GET /healthz that checks Postgres and Redis. One route suite runs on both runtimes in CI.

Writes improved 1.8x while reads improved 4.3x, and we know why: the create path still runs CREATE TABLE IF NOT EXISTS and ALTER TABLE ... ADD COLUMN IF NOT EXISTS on every insert, on both runtimes. Writes are DDL-bound. Moving DDL off the hot path is the obvious next optimization. A second known cost: the Redis-cache and default-schema path builds a new Ajv instance and compiles on each request. Only discovered schema files are cached in-process.

Toolchain timings

Node toolchain Bun
Flight install, cold cache npm ci 6.11 s bun install 0.44 s
Scribe install, cold cache 6.42 s 0.86 s
Scribe test cycle npm test 10.59 s (tsc build + node --test, 40 tests) bun test 0.87 s (51 tests: the same 3 shared files plus Bun-only checks)

CI and the docs still run npm ci, even in the Bun CI job, so bun install is optional today. Flight's two test suites differ (30 Node tests vs 6 Bun tests), so we do not compare them.

How to reproduce

The scripts and raw outputs are in each repo's bench/ folder: https://github.com/thoughtpivot/flight/tree/main/bench and https://github.com/thoughtpivot/scribe/tree/master/bench. You need wrk, hyperfine, taskset (Linux), and Bun. For Scribe you also need local Postgres and Redis and SCRIBE_APP_DB_PASS set.

# Flight, one core: c = Bun-native, b = Koa file on Bun, t = Node + tsx, a = Node + ts-node
source bench/env.sh
bench/throughput.sh c 4003 1          # server on core 1, wrk on cores 4-7
W=4 bench/throughput.sh t 4004 0-3    # Node cluster on 4 cores

# Scribe, reads and writes on one core
bench/throughput.sh sb 5002 1 100 /notes/1
bench/throughput.sh sb 5002 1 50  /notes bench/post.lua
Enter fullscreen mode Exit fullscreen mode

throughput.sh does the 10 s warmup, the three 20 s runs, and the idle and after-load RSS of the whole process tree. startup.sh times exec to the first 200. Run it a few times and expect some spread on a shared machine. If your numbers differ, open an issue with them. We would like to see where they land.

What we have not measured yet

  • End-to-end Flight to Scribe to Postgres with a real Vue or React app
  • Several Bun processes (reusePort, or replicas) against a Node cluster on equal cores
  • Container image size and Kubernetes pod cold start
  • Production-mode Flight with compression, Redis sessions, rate limiting and static SPA serving on both sides
  • A soak of an hour or more for memory growth
  • CI wall time if the Bun job switches to bun install

One known issue: express-bunyan-logger records a request in src/bun/dependencies.test.ts is flaky under Bun. It failed in 2 of about 11 local runs.

Try it

npm install @thoughtpivot/flight   # then run: flight-bun   (needs Bun on PATH)
npm install @thoughtpivot/scribe   # then run: scribe-bun   (needs Bun on PATH, plus Postgres and Redis)
Enter fullscreen mode Exit fullscreen mode

flight-bun needs Bun on your PATH and listens on port 3000 by default (set FLIGHT_PORT to change it). scribe-bun also needs Bun on your PATH, and it needs a running Postgres and Redis.

The Node binaries (flight, scribe) are unchanged.

Credit

Early Bun research and benchmarks for Flight and Scribe by Vasilen Lyubomirov (github.com/VasilenL), whose work helped start this.

Source: github.com/thoughtpivot/flight and github.com/thoughtpivot/scribe

#thoughtpivot

Top comments (0)