Benchmarks · measurement-first

LOOK vs Node.js vs PHP — the honest numbers.

Same hardware (2 CPU / 4 GB), same trivial JSON and database endpoints, measured with the same tools. No cherry-picking: we show where LOOK wins, where it loses, and — the point of this page — how we made sure every number is real. Then we sent it 10.5 million requests and watched what happened.

10.5M
requests over 8 min — 31 timeouts, zero crashes
7MB
RSS, flat before & after 10.5M requests — no leak
~200µs
p99 latency, flat from 1k to 20k connections
0
crashes · restarts · OOM kills, in every run

Environment: 16-core host, Docker; each server hard-capped to --cpus=2 --memory=4g. The load generator (wrk) runs uncapped on the remaining cores so it is never the bottleneck. All results are best-of-3. Harness: cpp/bench/race.sh in the repo — one command, fully reproducible.

How we measured — and what we threw out

A benchmark is only as good as its setup. During this work we caught seven confounds that would each have produced a wrong headline. The discipline was simple: a claim is a hypothesis until a clean, isolated measurement confirms it. Reading the source generates ideas; measurement decides.

1
Measure the load tool's own ceiling first. An early "10 req/s" result turned out to be a single-threaded downstream capping itself at ~5 req/s — not LOOK. Always prove the generator isn't the bottleneck.
2
Verify the response is real (HTTP 200). An SSRF guard was returning status 0 (fast-fail), silently invalidating hours of numbers. Eyeball the body before trusting the rate.
3
Keep the script off a slow bind-mount. The server re-checks the script's timestamp for hot-reload; on a mounted filesystem that stat() per request cost ~600 µs. Moving the script to local disk: 1,461 → 11,226 req/s at c=1.
4
Don't compare absolute numbers across toolchains. A fix measured 570 ms on MSVC and 786 ms on GCC — not a regression, just different compilers. Use a self-contained discriminator inside one binary.
5
Under a CPU quota, read cpu.stat before trusting tail latency. This one was the biggest — see below. A ~40 ms p99 "weakness" was pure CFS throttling, invisible in strace. A 30-second cat would have found it; five network hypotheses and a packet capture did not.

The 40 ms mystery — solved, and it wasn't LOOK

Under a hard --cpus=2 quota, LOOK's default workers = CPUs × 4 = 8 threads burst past the CFS bandwidth limit; the kernel then throttles every thread until the next ~100 ms period, so an in-flight request stalls. 92% of CFS periods were throttled. Node's 2 cluster workers can't exceed the quota, so it never showed. The fix is one setting — LOOK_WORKERS=2 — which the server now warns about at startup. Result below in the default vs tuned rows: p99 63 ms → 266 µs, throttling 243 → 0, and throughput went up 2.6×. Fixing it fixed both axes, so it was pure waste.

Workload 0 — the language core (pure CPU, no HTTP)

Before any web framework, how fast is the language itself? Seventeen micro-benchmarks — arithmetic, loops, function calls, strings, arrays, maps, JSON, exceptions — each implemented identically in LOOK, Node and PHP, run with an integer checksum that must match across all three (a mismatch means the implementations diverged, not a result). This is LOOK's hardest arena: a bytecode VM interpreting each operation against V8's and PHP's JITs, with no I/O to amortize. We publish it as-is.

benchmarkLOOKNodePHPLOOK ÷ Node
exception103 ms2732 ms209 ms0.04× — 26× faster
json_parse690 ms346 ms444 ms2.0×
json_serialize602 ms144 ms91 ms4.2×
assoc_access1400 ms192 ms130 ms7.3×
loop2781 ms360 ms478 ms7.7×
int_arith634 ms78 ms106 ms8.1×
fn_call1139 ms72 ms91 ms15.9×
recursion316 ms14 ms22 ms22.5×
array_push_pop2282 ms80 ms233 ms28.5×
float_arith932 ms32 ms32 ms29.1×
string_concat1739 ms54 ms128 ms32.5×
nested_fn1328 ms36 ms81 ms37.0×
string_search149 ms3.6 ms4.7 ms41.4×
array_create_iterate1868 ms37 ms55 ms50.4×
object_create1467 ms18 ms141 ms81.8×
string_slice1156 ms8.7 ms19 ms132.9×
regex17018 ms113 ms65 ms151×

Node's JIT wins the compute microbenchmarks, mostly by 8–40× — that is structural: it compiles each hot loop to machine code, LOOK interprets it. Two honest caveats keep the table fair. exception is the one LOOK wins outright — 26× faster than Node — because throwing is cheap in LOOK and expensive in V8; real request-handling code throws far more than it runs tight arithmetic loops. And regex is not an engine-speed number: ~95% of that time is a ReDoS safety guard (a per-call thread bounding catastrophic backtracking), not matching — the raw match is ~1.9 µs/call.

Why this rarely shows on the web: the VM's per-instruction dispatch is about 0.5% of a real request's cost (≈0.4 µs of ~90 µs) — the rest is HTTP parse, routing and response building. A microbenchmark magnifies the one layer that barely moves the needle in production (Workloads 1–2 below). It is published because hiding your worst arena is not measurement-first.

Re-verified this cycle. A fresh three-way run reproduced every row — all 17/17 checksums matched and the LOOK÷Node ratios held. The only movement was a few-percent rise on the arithmetic rows (int_arith, loop, float_arith), which tracked the test laptop's thermal state under sustained load (the numbers climbed monotonically across back-to-back runs while the engine was unchanged) — machine drift, not a language change. So the representative numbers above stand rather than being overwritten with warm-machine noise.

Where it improved — and where it regressed

The question "did the language get faster or slower?" is only answerable by comparing the previous build against the current one in the same run (drift cancels; a correctness suite can't see speed). We do exactly that every cycle. This one moved four numbers:

2 faster
this cycle's allocation fixes
  • string_slice +49% — substr stopped decoding the whole input per call
  • object_create +28% — assoc literals stopped re-allocating constants
1 caught
found & fixed by the same scan
  • a new opcode inserted mid-enum silently taxed the hot dispatch path — int_arith / loop ~13% slower, on code we never touched
  • moving the opcode to the enum end recovered it in one line; the arithmetic numbers above are the fixed values

No functional test — not even our three-engine differential suite — catches a codegen-layout regression like that; only an old-vs-new speed scan does. So it became a permanent rule in the harness: any change to the opcode set or the dispatch loop must ship with an old/new regression scan. This was the fourth time this session a real defect lived in a behavior no test was asserting.

Workload 1 — trivial JSON (CPU-bound)

Measured on build f2c2230 — still representative, with one open number

Workloads 1–2, the tail chart, the ceiling table and the endurance run were measured on a 16-core Linux host. We re-checked this cycle's changes against them the drift-immune way (counting allocations per request, which hardware can't distort): the framework request path was unchanged (193 → 193 allocations), and the string/map fixes moved it −4% — so the throughput, tail and RAM below still hold. One change is not yet reflected: a new per-request cache cut framework allocations 201 → 94 (2.1×), which can only raise throughput and can't hurt the tail — so these numbers are a conservative floor for the current build, not stale. A faithful re-run to capture that gain is queued for an isolated Linux box (never the production VPS, whose live sites share its CPU). Workload 0 above is on the current build.

A route that returns {"ok":true,"msg":"hello"} — no I/O, pure runtime overhead. This is LOOK's worst case: nothing amortizes the interpreter, so a JIT (V8) has the largest possible edge. We show LOOK both at its default worker count (throttled) and correctly tuned.

runtimec=50 req/sc=100 req/sc=1000 req/sp99 @100p99 @1000RAMthrottled
LOOK default w=819,80917,55416,13945 ms1.56 ms9.9 MB182
LOOK tuned w=221,48321,75222,163228 µs212 µs1.85 MB0
Node.js cluster×291,59193,33673,9492.16 ms1.17 s91 MB88
PHP fpm+nginx4,3524,4314,45564 ms488 ms93 MB162

Node wins raw throughput ~4× — V8's JIT compiles the trivial handler to near-native code; LOOK's bytecode VM interprets it. That's structural and expected. But look at p99 @ c=1000: Node's tail collapses to 1.17 s while LOOK (tuned) holds 212 µs — and LOOK does it in 1.85 MB of RAM against Node's 91 MB.

Workload 2 — database endpoint (I/O-bound)

The real web workload: SELECT id,name,email FROM users WHERE id=? → JSON, against MySQL 8.4 (kept at 0.5–1% CPU, so the database is never the bottleneck). LOOK uses its embedded driver + async pool; Node uses mysql2; PHP uses mysqli — each in its production shape.

runtimec=50 req/sc=100 req/sc=1000 req/sp99 @100p99 @1000RAM
LOOK default w=84,9993,5443,23055 ms55 ms4.2 MB
LOOK tuned w=410,24310,2779,460697 µs0.87 ms2.5 MB
Node.js ×2 + mysql214,61616,10612,46812 ms383 ms234 MB
PHP fpm+nginx+mysqli2,3602,3602,526271 ms698 ms97 MB

With real I/O the gap narrows — Node ~1.6× LOOK's throughput, LOOK ~4× PHP's. And again the tail inverts under load: at c=1000 LOOK's p99 is 0.87 ms vs Node's 383 ms, in 2.5 MB vs 234 MB of RAM.

Worker sweep (the data behind LOOK's default): I/O-bound peak is w=4 (9,487 req/s, p99 0.87 ms). w=2 leaves throughput on the table (6,607); w=8 over-throttles (7,240, p99 44 ms). The optimum is workload-dependent — CPU-bound wants ≈ the quota, I/O-bound a little more.

The real story — tail latency under load

Production isn't measured by "requests per second" alone; it's measured by what your worst user waits. This is where the two designs diverge most. As concurrency climbs, Node's p99 curves sharply upward — it is buying throughput by queueing. LOOK (tuned) stays a flat line.

100µs 1ms 10ms 100ms 1s+ c=50 c=100 c=500 c=1000 1.17 s 212 µs
LOOK (tuned) — p99Node.js — p99
Trivial JSON, p99 vs concurrency (log scale). Same throughput regime; the difference is saturation: Node queues, LOOK doesn't.

We deliberately avoid quoting the raw ratio ("5,500×"): the two systems are in different regimes at c=1000 — Node is saturated and queueing, LOOK is not. The honest statement is the shape of the curve: LOOK does not enter saturation where Node does.

Does LOOK hit a ceiling? Not on tail.

At c=1000, LOOK (tuned) is at 22k req/s with p99 212 µs and zero throttling — comfortable, not saturated. So we pushed both runtimes to 5,000, 10,000, then 20,000 concurrent connections — same tool, same box, so no one can say we only stressed one side.

concurrencyLOOK req/sLOOK p99Node req/sNode p99
1,00022,821140 µs75,3711.64 s
5,00021,310212 µs87,2052.41 s
10,00022,221184 µs81,5341.36 s
20,00016,946196 µs65,108159 ms

Measured symmetrically, the trade is stark: Node sustains ~3–4× the throughput (65–87k vs ~22k) — but its p99 sits in whole seconds (1.36–2.41 s) while LOOK holds a flat ~190 µs from 1,000 to 20,000 connections. Two threads, 20,000 connections, sub-millisecond tail. Neither runtime "breaks" — they occupy opposite corners: Node maximizes throughput and pays in tail; LOOK holds latency and caps throughput at what two interpreted cores can do (~22k).

Both runtimes dip at c=20,000 (LOOK 22k→17k, Node 81k→65k) — that is the single load-generating machine hitting its own limit (ephemeral ports / one wrk host driving 20k live sockets), not either server failing. The proof: LOOK's p99 is still 196 µs at that point, and Node's actually improves as its in-flight backlog thins. On a real fleet the client load would come from many hosts.

Stability — we sent it 10.5 million requests

Throughput and latency mean nothing if a server drifts, leaks, or falls over. So we ran three endurance tests and watched the process the whole time — resident memory, error counts, restart and OOM flags.

1.34M
60-second soak · c=1000
  • 4 timeouts (0.0003%)
  • RSS 7,424 → 7,424 kB
  • running · 0 restarts
20,000
concurrent · 20 s sustained
  • 2 timeouts · p99 228 µs
  • RSS flat · no OOM
  • running · 0 restarts
10.5M
8 minutes · 21,995 req/s
  • 31 timeouts (0.0003%)
  • RSS 7,168 → 7,168 kB
  • p99 219 µs, flat
  • running · 0 restarts · no OOM

10,558,613 requests over 8 minutes: RSS identical before and after, p99 flat at 219 µs, zero crashes, zero leaks, zero OOM kills. LOOK finished exactly as it started. This comes from the design — a single binary, a fixed worker pool (no thread-per-request), and no per-request allocation growth — so 20,000 live connections are held in 7 MB. For comparison, the Node database server in the same box sat at 234 MB.

The honest verdict

There is no single winner — there are three axes, and each runtime is built for a different point.

axiswinnerby how much
Raw compute (CPU micro)Node.js8–40× on arithmetic/strings (JIT vs bytecode VM) — except exceptions, where LOOK is 26× faster
Raw throughputNode.js~4× (CPU-bound), ~1.6× (DB) over tuned LOOK
p99 tail latency under loadLOOKflat sub-ms to 20k conns; Node curves to 1.17 s
MemoryLOOK7 MB vs 91–234 MB — 13–95× less
Stability / enduranceLOOK10.5M requests, no crash · no leak · no OOM
DeploymentLOOKone static binary, no runtime, no package tree

If your metric is maximum requests per second on a trivial CPU-bound path, Node's JIT wins. If your metric is what your worst user waits under load, how much memory a fleet costs, or whether it's still standing after ten million requests, LOOK is the design that holds — predictable, tiny, and it doesn't fall over. PHP trails on throughput in both workloads.

Reproduce every number

Nothing here is hand-typed. The whole suite — the apps, the runners, and the eight measurement-hygiene rules learned the hard way — is in the repository under cpp/bench/. Run bash cpp/bench/race.sh <repo> for the web workloads, and bash cpp/bench/micro/compare.sh for the language core, on your own hardware. Web workloads (1–2) measured on build f2c2230; the language core (Workload 0) on the current build with the dispatch fix applied.