benchmarks for bun vs OCaml in a hello world
  • Python 35.8%
  • HTML 29.3%
  • OCaml 19%
  • Dune 6.4%
  • Makefile 3.3%
  • Other 6.2%
Find a file
David Sancho Moreno 33e150eb51 Add Go HTTP benchmarks
2026-08-28 14:21:26 +00:00
docs Explain report table metrics 2026-08-27 08:52:14 +00:00
research Document Bun fetch benchmark results 2026-08-27 08:51:53 +00:00
servers Add Go HTTP benchmarks 2026-08-28 14:21:26 +00:00
.gitignore Hello-world web server benchmark 2026-08-25 11:31:21 +00:00
autoresearch.jsonl Record final Vif tuning results 2026-08-26 16:06:25 +00:00
autoresearch.sh Start Vif multicore autoresearch segment 2026-08-26 15:03:23 +00:00
bench.py Add Flambda and multicore OCaml benchmarks 2026-08-26 09:12:04 +00:00
Makefile Add Go HTTP benchmarks 2026-08-28 14:21:26 +00:00
patch-trail-lock.py Update OCaml servers and publish dependency versions 2026-08-25 16:47:56 +00:00
PRODUCT.md Expand benchmark matrix and publish interactive report 2026-08-25 15:26:35 +00:00
README.md Add Go HTTP benchmarks 2026-08-28 14:21:26 +00:00
report-template.html Add Go HTTP benchmarks 2026-08-28 14:21:26 +00:00
report.py Add Go HTTP benchmarks 2026-08-28 14:21:26 +00:00
servers.json Add Go HTTP benchmarks 2026-08-28 14:21:26 +00:00

Hello-world web server benchmark

One project to compare minimal "Hello, World!" HTTP servers across runtimes:

View the latest benchmark report.

Server Stack
elysia-bun Elysia on Bun
bun-fetch-native Native Bun.serve fetch handler
hono-bun Hono on Bun
h3-bun H3 on Bun
node-http-bun Bun's node:http compatibility layer
node-http-scriptc scriptc native node:http implementation
express-bun Express on Bun
fastify-bun Fastify on Bun
go-net-http Go standard library net/http on one CPU
go-net-http-multicore Go net/http on four CPUs
go-fasthttp Go fasthttp on one CPU
go-fasthttp-multicore Go fasthttp on four CPUs
dream OCaml Dream on Lwt
dream-flambda Dream built with Flambda enabled
opium OCaml Opium on http/af and Lwt
vif OCaml Vif on httpcats and Miou
vif-multicore Vif with eight Miou domains and a 640k-word minor heap
trail OCaml Trail, Nomad, and Riot
httpcats OCaml httpcats on Miou
httpcats-flambda httpcats built with Flambda enabled
httpcats-multicore httpcats with eight domain-local Miou accept loops and a 1M-word minor heap
cohttp-eio OCaml Cohttp on Eio
cohttp-eio-flambda Cohttp Eio built with Flambda enabled
cohttp-eio-multicore Cohttp Eio with four domains
cohttp-lwt OCaml Cohttp on Lwt
httpun-eio OCaml httpun on Eio
httpun-eio-multicore httpun Eio with four domains
httpun-lwt OCaml httpun on Lwt
httpaf OCaml http/af on Lwt
httpaf-flambda http/af built with Flambda enabled
tiny-httpd OCaml tiny_httpd with system threads
tiny-httpd-flambda tiny_httpd built with Flambda enabled
tiny-httpd-moonpool tiny_httpd with a one-thread Moonpool work-stealing pool
tiny-httpd-moonpool-multicore tiny_httpd with a four-thread Moonpool work-stealing pool

Every server does the same thing: GET /200 text/plain "Hello, World!", listening on $PORT (default 8080), single process, no logging middleware. The harness validates that contract before warmup. Baselines and Flambda builds use one logical CPU. Multicore entries declare their CPU count and receive it in BSER_CPU_COUNT.

Each OCaml server is its own standalone dune project (with its own dune-project and dune-workspace) using dune package management: dune pkg lock solves and locks that server's dependencies into a private dune.lock/, so version constraints of one stack (say httpcats' h1/miou) can never collide with another's (Dream's lwt ecosystem) — no opam switch needed.

Measurements

For each server, bench.py reports:

  • Req/s — sustained requests per second over the measurement window.
  • Latency — average, p50, p90, p99, and max, in milliseconds.
  • Throughput — response bytes per second (MB/s).
  • Peak / avg memory — RSS of the whole server process tree, sampled from /proc every 200 ms during the measurement window.
  • CPU — CPU time consumed by the server process tree during the window, reported as a percentage of one core.
  • Errors — socket errors, timeouts, and non-2xx responses.

Prerequisites

  • Linux (resource sampling reads /proc).

  • Bun for the TypeScript servers. Reports identify TypeScript 7.0.2.

  • Node.js >= 24 and clang >= 15 for the scriptc compiler. The compiled server does not require Node.js. Zig is also supported with SCRIPTC_CC=zigcc.

  • Go 1.27 for the Go servers.

  • System libraries used by the OCaml dependency trees — dune builds the OCaml side but not C depexts (libev for lwt/dream, gmp for zarith, openssl for ssl/tls):

    sudo apt-get install -y libev-dev libssl-dev libgmp-dev pkg-config
    
  • dune >= 3.24 with package management for the OCaml servers — easiest via the standalone dune binary (no opam required):

    curl -fsSL https://get.dune.build/install | sh
    

    Then, per server (or make deps / make build for all of them):

    cd servers/dream
    dune pkg lock   # solve + lock this server's deps into dune.lock/
    dune build      # fetch, build deps, and build the server
    

    Locks are per project. Commit the generated dune.lock/ directories if you want reproducible benchmark builds across machines.

    Every OCaml server is locked to OCaml 5.5.0. Flambda projects also lock ocaml-option-flambda; their private compiler and dependencies are rebuilt with Flambda enabled.

  • A load generator, one of (checked in this order):

    • oha — recommended (cargo install oha)
    • wrk
    • autocannon (bun add -g autocannon; also found via bunx/npx)

Running

Start with a single OCaml server before bringing up the full matrix — make first locks and builds dream only, then runs a short benchmark of it against the Bun baseline:

curl -fsSL https://get.dune.build/install | sh   # recent dune, once
make first

Once that works, the same flow scales to everything:

make deps      # bun install + dune pkg lock for every server
make bench     # full run: 30s per server, 64 connections, 5s warmup
make smoke     # quick 3s-per-server pipeline check

Or drive bench.py directly:

python3 bench.py --duration 60 --connections 256 --tool oha
python3 bench.py --servers dream,httpaf --no-build
python3 bench.py --server-cpu 2 --servers bun-fetch-native,httpaf
python3 bench.py --list

Each run writes to results/<timestamp>/:

  • results.json — full metrics, resource samples, config, and environment,
  • results.md — a ready-to-paste Markdown table,
  • report.html — a standalone interactive report,
  • <server>.log — stdout/stderr of each server.

Servers run one at a time on the same port; each is built, pinned to its declared CPU count, started, contract-checked, warmed up, measured, and torn down before the next starts. The HTML report hides multicore results by default; use its show multicore control to include them. Bun processes always receive NODE_ENV=production.

Methodology notes

  • Run on an idle machine; close other workloads. Results from laptops with thermal throttling are noisy — prefer several runs (for i in 1 2 3; do ...) and compare medians.
  • Load generator and server share the machine here, so they compete for cores. For serious numbers, run the generator from a second machine against the server's IP (start servers by hand with PORT=8080 <run cmd>).
  • The harness reserves a server CPU pool large enough for the selected entries. Use --server-cpu to select its first logical CPU. One-CPU entries use only that CPU. The load generator uses a stable, disjoint CPU set.
  • Multicore results use each entry's declared CPU count. OCaml httpcats and Vif use eight; the other multicore entries use four. They are an explicit scaling axis, not direct replacements for the one-CPU rankings. Logical CPU topology still matters; use separate physical cores for publishable results.

Status / caveats

  • Every server with third-party dependencies has its own lock and is exercised by make smoke.
  • Trail pins Riot commit 310a486 because the latest Riot release restricts OCaml to versions older than 5.3. patch-trail-lock.py applies three narrow OCaml 5.5 compatibility fixes to the archived Config, Riot, and Nomad sources.
  • H3 2 is currently a release candidate; its lockfile fixes the tested version.

Adding a server

  1. Create a directory under servers/ with the implementation. Read $PORT, respond to GET / with text/plain Hello, World!.
  2. Add an entry to servers.json with name, runtime, cwd, optional build, and run (the command must stay in the foreground). For a multicore entry, add cpu_count and read the assigned total from $BSER_CPU_COUNT.
  3. python3 bench.py --servers <name> --duration 3 to try it out.