Our /v1/metadata/{id} endpoint serves the little box of data that renders under every video thumbnail: title, channel, duration, view count, localized descriptions, and the region-specific trending rank. On a normal Asia-Pacific evening it handles around 9,000 requests per second across Japan, Korea, Taiwan, and Singapore, and every one of those requests fans out to SQLite, a Redis cache, and occasionally a slow upstream call to normalize CJK text. When our p99 latency crept past 180ms during Golden Week traffic, I stopped tuning our PHP path and asked a narrower question: if I rebuilt just this hot endpoint as a standalone JSON service, would Bun's runtime actually beat Node's, or is that just launch-week benchmark theater? This post is the honest measurement I ran for TopVideoHub, including the parts where Bun lost.
Why a JavaScript runtime touches a PHP site at all
Our main stack is PHP 8.4 behind LiteSpeed, with SQLite FTS5 (custom CJK trigram tokenizer) for search and Cloudflare in front. That stack is excellent at rendering full HTML pages and it caches beautifully. But the metadata endpoint is different: it is called by our own front-end JavaScript, by our native mobile clients, and by a handful of syndication partners. It returns compact JSON, it is read-heavy, and it is latency-sensitive because it blocks thumbnail hover previews.
Running that through the full LiteSpeed + PHP request lifecycle is wasteful. Each request pays for process pool checkout, framework bootstrap, and header machinery designed for HTML responses. A dedicated micro-runtime that keeps a warm connection pool, holds prepared statements in memory, and speaks only JSON is a much better fit. The question was which runtime. Node 22 is the safe default. Bun 1.2 promises faster startup, a faster HTTP server, and a built-in SQLite driver. I wanted numbers from our workload, not a hello-world.
Defining a workload that resembles reality
Most runtime benchmarks are useless because they measure an empty res.end('hello') loop. That measures the runtime's event loop and nothing else. Real endpoints do work between accepting the socket and writing the response. Ours does three things per request:
- A primary key lookup in SQLite for the base video row.
- A JSON parse/merge of a cached localization blob (the CJK title variants).
- A small amount of CPU work: building the response object, formatting durations, and computing a region-aware trending badge.
To keep the comparison fair I wrote the service logic once and ran it on both runtimes with identical code, using each runtime's native SQLite binding (node:sqlite on Node 22, bun:sqlite on Bun). Here is the core handler, unchanged between runtimes except for the import line:
// handler.ts — identical logic on both runtimes
import { Database } from "bun:sqlite"; // Node: import { DatabaseSync as Database } from "node:sqlite"
const db = new Database("metadata.db", { readonly: true });
db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
const stmt = db.prepare(
"SELECT id, channel, duration_s, views, locales FROM videos WHERE id = ?"
);
export function buildMetadata(id: string, region: string) {
const row = stmt.get(id) as any;
if (!row) return null;
const locales = JSON.parse(row.locales); // CJK title/description variants
const primary = locales[region] ?? locales["en"];
return {
id: row.id,
channel: row.channel,
title: primary.title,
description: primary.description,
duration: formatDuration(row.duration_s),
views: row.views,
trending: row.views > 500_000 && region in locales,
};
}
function formatDuration(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return h > 0
? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`
: `${m}:${String(sec).padStart(2, "0")}`;
}
The HTTP layer is where the runtimes genuinely differ. Bun exposes Bun.serve, a native server. Node 22 has a stable built-in node:http server and, more interestingly, native fetch-style handling is not the fast path — so I gave Node its best shot with a tuned http server and keep-alive enabled.
// server-bun.ts
import { buildMetadata } from "./handler";
Bun.serve({
port: 3001,
fetch(req) {
const url = new URL(req.url);
const id = url.pathname.split("/").pop()!;
const region = url.searchParams.get("region") ?? "en";
const data = buildMetadata(id, region);
if (!data) return new Response("null", { status: 404 });
return Response.json(data);
},
});
The measurement harness
I do not trust a load test I cannot rerun deterministically, so the harness is a small Python script that drives a fixed request mix and records latency percentiles from the client side. Client-side percentiles matter: server self-reported timings hide queueing delay, and queueing is exactly what falls apart under load. The script replays a sample of real video IDs drawn from our access logs so the SQLite cache behaves like production rather than hammering one hot row.
# bench.py — closed-loop load generator with client-side percentiles
import asyncio, time, random, statistics
import aiohttp
IDS = [line.strip() for line in open("sample_ids.txt")]
REGIONS = ["ja", "ko", "zh-TW", "en", "th"]
CONCURRENCY = 128
DURATION_S = 30
async def worker(session, base, latencies, stop_at):
while time.monotonic() < stop_at:
vid = random.choice(IDS)
region = random.choice(REGIONS)
t0 = time.perf_counter()
async with session.get(f"{base}/v1/metadata/{vid}", params={"region": region}) as r:
await r.read()
latencies.append((time.perf_counter() - t0) * 1000)
async def run(base):
latencies = []
stop_at = time.monotonic() + DURATION_S
conn = aiohttp.TCPConnector(limit=CONCURRENCY)
async with aiohttp.ClientSession(connector=conn) as session:
await asyncio.gather(*[
worker(session, base, latencies, stop_at) for _ in range(CONCURRENCY)
])
latencies.sort()
n = len(latencies)
print(f"requests={n} rps={n / DURATION_S:.0f}")
print(f"p50={latencies[int(n*0.50)]:.2f}ms "
f"p95={latencies[int(n*0.95)]:.2f}ms "
f"p99={latencies[int(n*0.99)]:.2f}ms "
f"max={latencies[-1]:.2f}ms")
asyncio.run(run("http://127.0.0.1:3001"))
Both services ran on the same 4-vCPU box (the same class of machine we rent for our Singapore edge workers), pinned to the same cores, with the database file warmed into the OS page cache before each run. I discarded the first 5 seconds of every run to skip JIT warm-up, and I ran each configuration five times and report the median run to blunt noise.
What the numbers actually said
Here is the median of five 30-second runs at 128 concurrent connections against the real ID mix. These are my machine's numbers; treat them as directional, not universal.
| Metric | Node 22 | Bun 1.2 |
|---|---|---|
| Throughput (rps) | 41,200 | 58,700 |
| p50 latency | 2.9 ms | 2.0 ms |
| p95 latency | 6.1 ms | 4.3 ms |
| p99 latency | 11.8 ms | 9.4 ms |
| Cold start to first response | 410 ms | 92 ms |
| RSS at steady state | 118 MB | 74 MB |
Bun won throughput by roughly 42% and shaved meaningful latency off every percentile. Two numbers mattered most for us. First, cold start: 92ms versus 410ms is the difference between a serverless-style worker that can scale to zero and one that cannot. We run region-specific workers that spin up during local prime-time and idle overnight; fast cold start is real money. Second, RSS: 74MB versus 118MB means more workers per box, which for a bootstrapped operation is the whole game.
But the averages hide the story. When I pushed concurrency to 512, Node's p99 degraded more gracefully than I expected and Bun's tail latency got spikier — occasional 40ms outliers that I traced to garbage collection pauses under the JSON-heavy load. If your SLO is written against p99.9 rather than p99, that spikiness is a real consideration, and it is exactly the kind of thing a hello-world benchmark never surfaces.
The built-in SQLite driver is the real advantage
The HTTP server speed is nice, but the biggest practical win was bun:sqlite. Our workload is dominated by that primary-key lookup and JSON parse, not by socket handling. Bun's native SQLite binding is measurably faster at the stmt.get() call than the equivalent path, and crucially it ships in the runtime — no native module compilation, no node-gyp, no rebuild-after-every-Node-upgrade tax. On Node 22 the new node:sqlite module closes most of that gap and I expect it to fully close over time, but at the time of testing Bun's driver was ahead on our exact query shape.
I isolated the database layer from HTTP entirely to confirm this, because I did not want to credit the server for the driver's work:
// db-only micro-benchmark, no HTTP involved
import { buildMetadata } from "./handler";
const ids = readIds("sample_ids.txt"); // 10k real IDs
const regions = ["ja", "ko", "zh-TW", "en"];
const t0 = performance.now();
let ops = 0;
for (let i = 0; i < 2_000_000; i++) {
const id = ids[i % ids.length];
const region = regions[i % regions.length];
buildMetadata(id, region);
ops++;
}
const elapsed = (performance.now() - t0) / 1000;
console.log(`${(ops / elapsed / 1000).toFixed(0)}k ops/sec`);
Running that pure loop, Bun did roughly 1.9M ops/sec and Node did roughly 1.4M ops/sec on the same data. That single result reframed the whole comparison for me: the HTTP layer differences were partly a proxy for the database driver being faster. If your endpoint is I/O-bound on an external service rather than on a local embedded database, expect the gap to shrink dramatically, because both runtimes will spend most of their time waiting on the network, not executing your code.
Keeping PHP honest in the same test
I would be lying if I framed this as Bun replacing our stack. It replaces one endpoint. To keep myself honest I benchmarked the existing PHP path against the same request mix, because if PHP was within noise there was no reason to add a second language to our deploy pipeline. Here is the equivalent PHP handler we run today under LiteSpeed:
<?php
// metadata.php — current production path under LiteSpeed
declare(strict_types=1);
$db = new SQLite3(__DIR__ . '/metadata.db', SQLITE3_OPEN_READONLY);
$db->exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;');
$stmt = $db->prepare(
'SELECT id, channel, duration_s, views, locales FROM videos WHERE id = :id'
);
$id = $_GET['id'] ?? '';
$region = $_GET['region'] ?? 'en';
$stmt->bindValue(':id', $id, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if ($row === false) {
http_response_code(404);
echo 'null';
exit;
}
$locales = json_decode($row['locales'], true);
$primary = $locales[$region] ?? $locales['en'];
$seconds = (int) $row['duration_s'];
header('Content-Type: application/json');
echo json_encode([
'id' => $row['id'],
'channel' => $row['channel'],
'title' => $primary['title'],
'views' => (int) $row['views'],
'trending' => $row['views'] > 500_000 && isset($locales[$region]),
]);
Under LiteSpeed with OPcache warm, this path sustained about 14,500 rps with a p99 of 21ms. That is genuinely respectable — LiteSpeed's PHP handling is far better than the FPM setups most benchmarks assume, and for a full HTML page it wins because of page caching. But for a pure JSON hot path called tens of millions of times a day, both JS runtimes roughly tripled the throughput and roughly halved the tail latency. The per-request bootstrap cost that PHP pays is exactly what a long-lived process avoids.
The operational costs nobody benchmarks
A runtime decision is not just a latency number. Here is what actually shaped my recommendation:
- Ecosystem maturity. Node's observability tooling (clinic, the built-in inspector, mature APM agents) is years ahead. Bun's inspector works but several of our profiling tools were flaky against it. If you cannot profile a production incident at 3am, raw speed is cold comfort.
- Native module surface. We deliberately kept this service dependency-light. The moment you need a native module Bun does not yet support, or an npm package that reaches into Node internals, you are debugging compatibility instead of shipping. Bun's Node compatibility is very good in 2026 but not total.
- Deploy pipeline. Adding a second runtime means a second base image, a second security patch cadence, and a second set of on-call runbooks. For a small team that overhead is not free.
- Tail-latency predictability. As noted, Bun's GC gave us occasional outliers under heavy JSON churn. Node's behavior was less spiky at extreme concurrency. Know which percentile your SLO actually targets.
- The upgrade treadmill. Bun ships fast and breaks things occasionally between minor versions. Node's stability guarantees are stronger. Pin your versions and read changelogs either way.
What I actually shipped
We moved the /v1/metadata endpoint to Bun, running as region-pinned workers behind Cloudflare, with the PHP + LiteSpeed stack still serving every HTML page, search (FTS5 with our CJK tokenizer), and the admin surface. The decision came down to three things specific to our situation: the endpoint is a self-contained JSON service with almost no dependencies, the fast cold start let us scale workers down overnight and save on our Singapore and Tokyo boxes, and the lower memory footprint let us pack more workers per instance. Bun's throughput win was the headline, but the cold-start and memory numbers were what actually moved the budget.
If your hot path is dependency-heavy, deeply integrated with the Node ecosystem, or bound by a slow upstream network call rather than local compute, I would stay on Node 22 — the gap narrows to noise and you keep the mature tooling. The honest conclusion is not "Bun wins" but "Bun wins this specific shape of workload": a stateless, dependency-light, CPU-and-embedded-DB-bound JSON endpoint under high concurrency. Benchmark your own request mix with client-side percentiles before you migrate anything. The runtime that wins a hello-world loop is not necessarily the one that wins your endpoint, and the only way to know is to measure the work you actually do.
Top comments (0)