We Benchmarked Bun 1.4 on Our Auth Service

Bun 1.4 is a rewrite of the entire runtime in Rust. Its own benchmarks promised +2-5% HTTP throughput on hello-world servers. We didn't trust the marketing — we benchmarked it against our real auth service, found two bottlenecks and a security gap, fixed all three, and measured the result.

Bun 1.4 is the first version of Bun written in Rust — a rewrite of the entire runtime, 535,496 lines of Zig ported to Rust. Its own benchmarks showed +2-5% HTTP throughput on hello-world servers. When a runtime rewrites itself, you should be skeptical: "we made it faster" is a claim, not evidence. So we measured instead.

We don't run hello-world servers. We run Identitas, the identity and auth service behind our platform — every login, every token validation, every session check passes through it. So we benchmarked Bun 1.4 against our real endpoints, found two bottlenecks and a security gap, fixed all three, and re-measured. This is what we found.

What we tested, and how

Eight endpoints that matter in production — health checks, OIDC flow start, login, registration, session lookups, service-to-service validation, and user provisioning. Every one real, no synthetic stubs.

We measured three things: sustained throughput (how many requests per second the server holds), concurrency limits (how many users can hit it at the same time), and connection churn (what happens when every request opens a new connection).

The method: a self-provisioning benchmark creates its own app and a pool of distinct users, then drives each endpoint through a concurrency ladder — 1, 2, 5, 10, 20, 50, 100, 200, 400, 800, 1200, 1600 concurrent requests — until the server plateaus or starts failing. We don't pick a fixed concurrency. We push until it breaks.

0 Endpoints tested
0 Max concurrent requests
~0k req/s Peak throughput
~0 /s Login capacity

The endpoints

The eight routes that carry our traffic, and what each one does:

  • /health and /version — liveness and version. Pure server overhead; the baseline.
  • /oauth/authorize — starts the OIDC login flow. Validates the client, checks the redirect, stores flow state. Database + Redis.
  • /oauth/login — email + password login. Verifies a password hash, mints JWTs, opens a session. The path we ended up fixing.
  • /oauth/register — creates a new user. Hashes the password, creates the account and roles, and auto-logs-in. A heavier path than login.
  • /oauth/me — the current user. Verifies the JWT, checks the session, loads roles. Database + Redis on every call.
  • /internal/validate — validates a token for a service-to-service call. Our hottest endpoint: it runs on every API request between services.
  • /internal/sync-user — provisions a user: upserts the user, their membership, and their roles. A database write path.

Sustained throughput limit (req/s)

version
84905
health
82826
oauth_authorize
19908
oauth_me
9986
internal_validate
9527
internal_sync_user
2557
oauth_login
412

Sustained req/s at the concurrency where the server levels off, 0 errors. After going Bun-native, login is no longer the outlier — the server handles it at 412 req/s here, up to ~1,260/s at 1,600 concurrent.

The changes we measured

After upgrading to Bun 1.4 and going Bun-native where it counts — native password hashing and the native database driver — every endpoint that touches the database got faster:

oauth_login throughput
Before 30 req/s
After 412 req/s
+13x
oauth_me throughput
Before 4,474 req/s
After 9,986 req/s
+123%
internal_validate throughput
Before 4,401 req/s
After 9,527 req/s
+116%
oauth_authorize throughput
Before 11,632 req/s
After 19,908 req/s
+71%

Latency: what users actually feel

Throughput is half the story; the other half is how long a user waits. Login latency dropped by 95% once hashing moved off the event loop, and the database paths roughly halved:

oauth_login (p50)
Before 926 ms
After 49 ms
-95%
oauth_me (p50)
Before 4.29 ms
After 1.89 ms
-56%
internal_validate (p50)
Before 4.32 ms
After 2.02 ms
-53%
internal_sync_user (p50)
Before 16.57 ms
After 7.58 ms
-54%

The questions that matter

How many users can log in at the same time? The server itself handles 1,500 concurrent logins in under three seconds. What you actually get is a separate question — per app, login and registration are deliberately rate-limited to stop brute force:

Server capacity — 1,500 concurrent logins
Before capped ~22/s (old hash)
After all ok in 2.9 s
~513/s
Login rate limit (per app)
Before not binding (gap)
After 100 / min
enforced
Register rate limit (per app)
Before not binding (gap)
After 50 / min
enforced
Sustained validations
Before 4,027 /s
After 8,609 /s
+114%

New connections cost more than you think

We also measured connection churn — every request opens a fresh TCP connection instead of reusing one. The impact depends entirely on how much work the endpoint does:

health (pure HTTP)
Before 80,456 req/s
After 24,257 req/s
-70%
oauth_me
Before 10,336 req/s
After 9,334 req/s
-10%
internal_validate
Before 9,716 req/s
After 8,727 req/s
-10%
The takeaway
Before connection reuse
After matters a lot
on I/O paths too

The bottlenecks Bun 1.4 exposed — and the fixes

Here's the honest part of the story. Benchmarking Bun 1.4 against our real service found two bottlenecks and a security gap, and fixing them is what doubled the throughput of our busiest endpoints.

The first: login was capped at ~22 logins per second. The culprit was bcryptjs — a pure-JavaScript bcrypt that ran the cost-10 hash check on the event loop. Every login blocked the main thread, concurrent logins queued up, and beyond ~100 concurrent they started timing out. We swapped it for Bun 1.4's native password hashing — same bcrypt algorithm and cost, so every existing hash still verifies — and login went from 30 to 412 req/s, now scaling with concurrency instead of capping.

The second: the database driver. Every endpoint that touches PostgreSQL used postgres.js, a pure-JavaScript driver, over a 10-connection pool. We moved the data layer to drizzle-orm/bun-sqlBun 1.4's native SQL client (prepared statements, pipelining) — and doubled the pool. Token validation went from 4,401 to 9,527 req/s, session lookups from 4,474 to 9,986, and user provisioning from 1,192 to 2,557.

Both changes were drop-in: same database, same schema, same queries, same hashes. The integration suite passes end to end — 130 tests, 0 failures — and then we re-ran the whole benchmark to prove it.

And the stress test found a third thing — a security gap, not a bottleneck. The per-app rate limits configured on the OAuth routes (login, register, token) were never binding: the limiter reads the app from the request context, but the public OAuth routes never set it. A single app could hit login at hundreds of requests per second. We fixed it by resolving the calling app from client_id before the limiter runs — the 100/min login and 50/min register caps now actually hold.

The full numbers

EndpointBefore (req/s)After (req/s)ΔLimit @1600 conc.
health68,59882,826+21%~83k peak
version69,81984,905+22%~85k peak
oauth_authorize11,63219,908+71%20.4k
oauth_login30412+13x~1.3k
oauth_me4,4749,986+123%10.0k
internal_validate4,4019,527+116%9.8k
internal_sync_user1,1922,557+114%4.0k (still climbing)

Sustained req/s at concurrency 20 (the practical operating point). "Limit @1600 conc." is the sustained rate at 1,600 concurrent requests with 0 errors. Before = bcryptjs + postgres.js (pool 10); After = Bun 1.4 native stack (Bun.password + Bun.SQL, pool 20). Single local machine (server + benchmark client together), mean of 3 runs.

The stress test, in one command

# Self-provisioning: creates its own app + a pool of distinct users.
bun scripts/bench.ts --url http://localhost:4005 \
  --master-key <key> --internal-key <key>    # fixed-duration throughput
bun scripts/bench.ts ... --saturation        # concurrency ladder to the limit
bun scripts/bench.ts ... --capacity          # concurrent logins + validations
bun scripts/bench.ts ... --no-keep-alive     # connection churn

How we measured it

A repeatable stress test, not a one-off:

Step 1

Define the endpoints

The eight routes that matter in production — health, OIDC start, login, registration, session check, service-to-service validation, user provisioning.

Step 2

Build the stress test

A self-provisioning benchmark: it creates its own app and a pool of distinct users, so every login and validation uses real per-user credentials.

Step 3

Saturation ladder

Each endpoint is driven through a concurrency ladder from 1 to 1,600 until the server plateaus or starts failing.

Step 4

Bottleneck #1: login

The ladder exposed login flat at ~22/s — bcryptjs was serializing the event loop. Swapped to Bun 1.4 native password hashing: 30 to 412 req/s.

Step 5

Bottleneck #2: the database driver

postgres.js over a 10-connection pool was the ceiling on every DB endpoint. Moved to Bun 1.4 native SQL (drizzle-orm/bun-sql) with a 20-connection pool: DB endpoints +71% to +123%.

Step 6

The rate-limit gap

The ladder revealed the OAuth per-app rate limits were never binding — a security gap. Fixed by resolving the app before the limiter runs; the 100/min login and 50/min register caps now hold.

Step 7

Ship the benchmark

The stress test lives in the repo, so anyone can re-measure the limits after every release.

From postgres.js to Bun.SQL

The driver story is older than Bun 1.4. We started on postgres.js — the battle-tested pure-JavaScript driver — because the native alternative was too young to trust with production. Our first attempt at going native, back in June on the old runtime, proved the instinct right: PostgreSQL silently closes idle connections, the pool handed out dead sockets, and users got 500s. We built a retry wrapper and stepped back to postgres.js.

Bun 1.4 changed the calculus, and memory is where we felt it first. The first Rust-written runtime ships a rewritten memory manager alongside a far more solid native SQL client: replaying the exact same A/B under both binaries, peak RSS fell from 150 to 103 MB for the native client and from 111 to 95 MB for postgres.js. A stabler client on a leaner runtime — that combination is what made us go native again. Both backends have run Bun.SQL in production since August: zero database-related incidents since, and the retry wrapper stays as a seatbelt.

The measurement settled it. Under light load (20 concurrent clients) the two drivers are indistinguishable — ~95k point-selects/s each, same machine, same Postgres, same queries. Under saturation (200 clients over an identical 50-connection pool) they are not even close:

Honest framing, as always: one dev machine, one Postgres 17 container, synthetic queries — treat the multiples as directional. But the mechanism is architectural, not magic: the native client pipelines queries across pooled connections, while postgres.js serializes a checkout-roundtrip per query. On a saturated read path, that is exactly where the time goes.

Saturated point-selects (200 conc., same pool)
Before 28.7k ops/s · p95 13.2 ms
After 120.8k ops/s · p95 2.68 ms
4.2x
Read p95 under saturation
Before 13.24 ms
After 2.68 ms
-80%
Writes (insert / update)
Before ~30.5k ops/s
After ~31k ops/s
parity
DB incidents since re-migration
Before 5x500 + recurrence (June)
After 0 since August
stable
Native-client peak RSS (same workload)
Before 150 MB on Bun 1.3
After 103 MB on Bun 1.4
-31%

The limits are the map

Benchmarking Bun 1.4 against our real auth service paid for itself immediately. We now know exactly where Identitas stands: ~85,000 req/s of pure HTTP, ~10,000 validations per second sustained even at 1,600 concurrent users, and a login path whose server capacity went from 30 to 412 logins per second — the server handles 1,500 concurrent logins in under three seconds.

Going Bun-native where it counts — Bun 1.4's native password hashing and native database driver — roughly doubled the throughput of our busiest endpoints: +116% on token validation, +123% on session lookups, +71% on OIDC start, +114% on user provisioning, with zero regressions and all 130 integration tests green. And the stress test exposed a security gap we closed: the per-app rate limits on login and register now actually bind (100/min and 50/min).

Knowing your limits is the difference between reacting to an outage and planning around it. This benchmark now runs after every release — and the first two times it ran, it found something real.

We measure our own production paths to find their real limits — and fix what we find. If that sounds like the kind of rigor you want on your platform, let's talk.