This is the whole server. It is the TypeScript you would write for Node, and it runs under Node unchanged:
import { createServer } from 'node:http'
const server = createServer((req, res) => {
if (req.url === '/json') {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
res.end('{"hello":"world"}')
return
}
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
res.end('Hello, World! ' + req.method + ' ' + req.url)
})
server.listen(3101)
We compiled that file to a native binary with geatsc — no Node, no V8, no JavaScript
engine in what runs — and put it on a benchmark box next to the servers people reach for when they
have decided JavaScript is too slow: hyper and axum in Rust,
Drogon in C++. Every server answers the same routes with the same bytes; the
harness refuses to take a sample until it has verified that.
The TypeScript won. At four workers it serves 313,046 requests per second. hyper serves 273,595. Drogon 243,009. axum 224,744. Node, running the identical file, 76,813.
Updated September 22: every number on this page was re-measured the day after publication, and a few moved. The note at the end says exactly what changed and why — the short version is that we named the wrong C++ compiler, measured the build flags instead of assuming them, and withdrew one claim we could not stand behind.
The numbers
One idle machine, one harness, three interleaved rounds, wrk -t4 -c64 with keep-alive,
8-second samples after a warm-up. Server pinned to two physical cores, load generator pinned to the
other two, so neither ever runs on the other's silicon.
Read the second row against the last one first, because that is the comparison nobody has to squint at: the same source file, 4.1× the throughput, one forty-second of the memory, and a cold start twenty times faster. Four compiled workers serve 313k requests per second in 4.3 MB. The Node cluster holds 183 MB to serve 77k.
Then read it against the rows above Node. At four workers the compiled TypeScript is 14% ahead of hyper, 29% ahead of Drogon and 39% ahead of axum, and it reaches 86% of a hand-written epoll loop that does no HTTP parsing at all — a ceiling we included precisely because nothing general-purpose should be able to touch it.
How sure are we?
Sure enough to say it, and careful about where. At four workers the two distributions do not touch: across six samples gea's worst was 311.6k and hyper's best was 279.0k, a 12% gap between the closest two samples. That held in every one of the five full runs we took over two days, including the ones with the slower build we published first.
On a single core the honest word is level. A single pinned core on this box is bimodal — every server, gea and hyper alike, lands in one of two clusters about 10% apart from round to round — so we ran a separate five-round pass of just these two servers. Medians: gea 141.7k, hyper 142.1k. The first version of this post read a 7% single-worker lead off three rounds; that was the bimodality, not the server, and we have withdrawn it. Our claim for one worker is "level with hyper, clear of axum and Drogon", and our claim for four is "ahead".
There is one measurement that does not move with the weather: instructions. We counted user-mode
instructions and cycles per request with perf stat on the pinned server.
The compiled IncomingMessage, ServerResponse, header dictionary and your
handler together execute 12% fewer instructions than hyper does for the same request, in 17% fewer
cycles. It shows up as a smaller throughput margin for an unglamorous reason: all three servers
spend the same ~15,000 kernel instructions per request on one recv and one
send. Two thirds of a hello-world request is the operating system, and no language
gets a discount on that.
And then we compiled Hono
A raw createServer is the floor. The question people actually have is whether a real
framework survives the trip. So: the real hono package from npm, the default router
stack, served through @hono/node-server. Nothing in Hono was modified, and the app
file is the same under both runtimes. These are its two measured routes (the full app also parses
JSON and multipart POST bodies):
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Hono!'))
app.get('/json', (c) => c.json({ hello: 'world' }))
serve({ fetch: (request, _env) => app.fetch(request), port: 3900 })
Compiled Hono is 3.7× Hono on Node. That is the headline we expected. The one we
did not: the whole Hono stack, compiled, is 1.9× faster than Node running no framework at
all. Context objects, WHATWG Request and Response,
Headers, the router — all of it, and it still laps bare node:http on
V8, in a thirty-sixth of Hono-on-Node's memory.
Where the speed comes from
Not from a trick, and not from a fast path for benchmarks. geatsc compiles TypeScript to C++ using
the types to choose native representations: a number is a double or an
integer, a class is a struct, a Map<string, T> is a typed container, a closure
is a closure. There is no interpreter, no JIT warm-up, no garbage collector, and no boxed value
unless the program itself is dynamic. node:http is written in TypeScript like any app
code and compiled by the same compiler, on top of a small C++ reactor.
The release that first produced these numbers moved one thing: the response head is no longer
built as a JavaScript string. writeHead, setHeader and end
now stream the status line, the fields and the framing straight into the connection's retained
output buffer. The Content-Length-or-chunked decision, the Date line and
the keep-alive tail are made natively, and the common tail is cached once per second. The release
after it changed no runtime code at all — only how the C++ is compiled — and that is the third
column.
The flags column is the part we did not expect, so here it is in full. We had been shipping
-O2 because that is what one ships. We tried the others — on both C++ compilers,
because the answer turned out to depend on which one — same emitted source, four pinned workers,
the raw server:
-O3 buys nothing. Link-time optimization buys 3%. And the smallest build is
the fastest one: -Os -flto is level with -O2 -flto on the raw
server and 5% ahead on Hono (10% on one core), at 30% less binary. Hono executes more
instructions per request at -Os (36,498 against 33,060) in fewer cycles
(30,688 against 33,925) — a compiled framework's hot path is bound by the instruction cache, and
smaller code wins. That is now the default. It is a clang result: g++ 13 on the same source is
4–10% slower at its best and loses 25% at -Os, so under g++ the build keeps
-O2 -flto, and the numbers on this page are clang's.
The bytes on the wire did not change. That is not a figure of speech: a separate parity suite runs
one app under real Node and as a compiled binary, throws 38 raw-socket requests at both — chunked
bodies, pipelining, POST with and without a body, malformed input — and requires the
responses to be byte-identical. All 38 are.
What we are not claiming
This is a hello-world benchmark over loopback. It measures the cost of the server, which is the thing we changed; your database will still be your database. A few specifics we would rather you hear from us:
- The tail is hyper's. At four workers gea's median latency is the best of the framework servers — 176 µs against hyper's 227 µs — but its p99 is 499 µs against hyper's 396 µs. The median is won; the tail is the open item.
- Rust is still smaller. hyper holds 2.3 MB to our 4.3 MB. Against Node's 183 MB we will take it, but we are not the smallest row in the table.
- One core is a draw. Said above, repeated here: on a single pinned core gea and hyper are level, and the box's single-core numbers swing 10% between rounds for every server. Rank by the four-worker column.
- Four workers, not eight, on purpose. The box has eight logical CPUs but four physical cores. Spread a server across all eight and it shares silicon with the load generator, the host saturates, and the ranking inverts — the fastest servers lose the most. An eight-worker run flatters us. We threw it away.
- The epoll loop is a ceiling, not a peer. It parses nothing and allocates nothing. We print it so you can see how much room is left: 14%.
Run it yourself
Everything above was built from the npm registry, not from a checkout on someone's laptop:
@geastack/compiler@1.0.16 and @geastack/node-compat@1.0.14, no symlinks,
compiled by clang 18 at -Os -flto. The harness, the control servers in Rust and C++,
every raw wrk report, the percentile latencies and the SHA-256 of every binary
measured are in the repository.
git clone https://github.com/geastack/node-compat && cd node-compat
npm ci # the compiler and node-compat, from npm
npm ci --prefix apps/hono-hello # hono and @hono/node-server, for the Hono rows
bash bench/goal-http-build.sh # gea servers + the Rust and C++ controls (clang++ -Os -flto)
python3 bench/http-matrix.py --output bench/results/mine.json \
--rounds 3 --duration 8s --workers 1 4 --server-cpus 0-3
That is the sequence we ran on the benchmark box for every number on this page. It needs Linux,
wrk, clang with its LTO plugin (CXX=g++ also works and lands 4–10%
lower), Cargo and Drogon's development libraries. The full method, the latency tables, the flag
study on both compilers and the things that bit us along the way are in
BENCHMARKS.md.
The deal, renegotiated
The server world has always offered two contracts. Take the npm ecosystem and the event loop, and pay for them with a runtime: a V8 heap per worker, a garbage collector in your tail, a hundred milliseconds before the first byte. Or take the performance, and rewrite the ecosystem in a systems language.
We compiled the ecosystem instead. The same import { createServer } from 'node:http',
the same import { Hono } from 'hono', the same bytes on the wire — ahead of hyper,
ahead of axum, ahead of Drogon, in four megabytes, up in six milliseconds.
You write TypeScript. The server runs machine code.
Update, September 22: what changed and why
We re-measured everything the morning after publishing, because a reader-sized question — "does stripping the binary change the memory column?" — turned into four findings. In the order we found them:
-
We named the wrong compiler. The post said the binaries were built with
g++; they were built withclang++ 18(our build script's default, which the benchmark script did not share — the binaries'.commentsections settled it). That mattered because g++ 13 is 4–10% slower on the same emitted source: anyone following our reproduce instructions would have gotten a slower server than the one we measured. The instructions now name clang, and the build script defaults to it.
-
We measured the flags instead of assuming them — the table above. The default
is now -Os -fltounder clang, which is where the 301k → 313k and 140k → 150k come from. No runtime code changed between the two columns.
-
Memory got smaller for a boring reason. The build had been linking
libcryptointo servers that never touchnode:crypto, and a mapped, relocated 4.5 MB library costs about 0.45 MB of PSS per process whether or not it is called. It is linked only when reached now; the smaller build accounts for the rest. Stripping, the thing we were asked about, changes nothing: symbol tables are never loaded.
- We withdrew the single-worker lead. The first version read "153k vs 143k" off three rounds. A dedicated five-round pass puts the medians at 141.7k and 142.1k; the single pinned core on this box is bimodal for every server. The four-worker result — the headline — was reproduced five times across two days and got larger.
The raw wrk reports for the original run are still in the repository next to the new
ones, so the "before" can be checked as easily as the "after".