Compiling Hono with Perry — GEA Blog

· GEA

7 min read Original article ↗

Perry 0.5.1520 ran our unchanged Hono app once we built it from source. On one CPU, Node served about eleven times as many requests. On a 38-request HTTP battery, Perry matched Node's bytes once and its status and body 36 times.

After our scriptc comparison, we tried another TypeScript-to-native compiler: Perry. We gave it the Hono app we use for our own compiler work, with the standard @hono/node-server adapter, and asked three questions: does it run, how fast is it, and does it answer like Node?

It runs, though not from the npm release: that package couldn't link or start our apps (details below), so we built Perry's compiler and runtime from the same source tag. We changed no application or dependency code. The build reported 30 native modules and no JavaScript fallback modules.

Versions: Perry 0.5.1520, Node v24.21.0, Hono 4.12.34, @hono/node-server 2.1.1, measured on September 23, 2026.

Perry in the wider comparison

This is the chart from our C++, Rust and Gea post with Perry added; the earlier rows are unchanged. Bars show the mean of three GET / samples, so they differ slightly from the medians in the Hono table below.

GET /, three-run means, linear scale from zero. Original benchmark results with Perry added from a separate run. Four-worker servers use CPUs 0–3; single-worker servers use CPU 0. The C++ epoll loop is a minimal control without HTTP parsing.

Raw HTTP and Hono are separate rows because they do different amounts of work. Perry's raw row is the unchanged apps/raw-http-hello/server.ts from the earlier post, run against Node on the same machine with the same pinning, 64 connections and three eight-second samples per endpoint. Both returned the expected status, content type and body, but Perry's headers and framing differ, so the earlier chart's “same bytes on the wire” condition does not hold for the Perry rows.

Hono on one CPU

Both runtimes run the same server.ts. The timed endpoints return Hello Hono! and {"hello":"world"}. Before timing, we checked those two responses, a 404 and a JSON POST handler for status, body and content type. Both passed.

Hono throughput on one CPU: median requests per second, three runs
EndpointPerry 0.5.1520Node v24.21.0Node / Perry
/2,829.8432,939.9611.64×
/json2,557.2227,672.3510.82×

Median p99 latency was 51.89 ms for Perry and 2.38 ms for Node on /, and 62.92 ms and 3.39 ms on /json. All 12 samples finished with no socket errors and no non-2xx/3xx responses.

Resident memory after each sample was 136.6–163.5 MiB for Perry and 112.0–114.7 MiB for Node (snapshots, not peaks). Perry's Hono executable is 25.9 MB.

Where the CPU time goes

Under load, both of Perry's binaries used a full CPU. The raw server averaged 44,509 requests per second; Hono averaged 2,795. Hono and its adapter cost about a factor of sixteen.

The Hono profile is dominated by Perry's JavaScript runtime machinery. About 48% of user-space cycles passed through its generic method dispatcher, 20% through promise microtask processing and 11% through minor garbage collection. These are inclusive call-stack figures, so they overlap and don't add up.

So the compiled binary still does dynamic dispatch, property lookup, promise scheduling and allocation on every request. The profile shows that this is where the time goes; it doesn't say how much of the gap to Node any one fix would close.

Perry with four workers

With a node:cluster launcher around the same handlers, Perry's raw server averaged 84,351 requests per second on / and Hono averaged 6,533. All twelve samples finished without socket or status errors.

Perry throughput with four workers: mean requests per second, three runs
Server//json
Perry · raw84,35187,743
Perry · Hono6,5336,022

What “1 out of 38” means

The compatibility battery is a separate raw node:http app, not 38 Hono routes. The driver sends the same requests to Node and to the compiled binary, including HEAD, HTTP/1.0, chunked uploads, pipelining, duplicate headers and malformed input, and compares the full response bytes with only the Date value normalized.

Perry matched once, on the 204 response. That score is stricter than it sounds. On a plain GET, Node sends a lowercase content-type and a chunked body; Perry sends Content-Type and a Content-Length. Both return Hello, World! GET /. The bytes differ; the answer doesn't.

The same 38 requests, compared two ways
ComparisonMatchesWhat it checks
Complete response bytes1 / 38Status line, headers, framing and body; Date value normalized
Status and decoded body36 / 38Ignores headers and transfer framing

The 36 doesn't mean 36 cases are fully compatible. Some differences live in headers, which that comparison ignores: HTTP/1.0 keep-alive handling, implicit HEAD content lengths, and two X-Extra fields merged into e1,e2.

Two requests got a different answer

In two cases the status and body both differed. Perry accepted a request that Node rejects each time.

RequestNode v24.21.0Perry 0.5.1520
20,000-byte header431, empty body200, Hello, World! GET /
Chunk size ZZ400, empty body200, echo::len=0

These matter more than header casing. The test shows what the whole stack returned; it doesn't pin down which layer is responsible, and we didn't look for an exploit.

Getting a working build

We started with @perryts/perry@0.5.1520 from npm. The raw HTTP app failed to link because libperry_ext_http.a was missing. Hono built, but the binary exited before opening its port with TypeError: Cannot read properties of undefined (reading 'listen').

We built the runtime libraries from the upstream v0.5.1520 tag. The npm compiler rejected them: same version number, different build identity (source 85ccd72f8e53 for the compiler, commit 381045a8735f for the libraries).

Building the compiler from the same tag fixed that. We left the identity check on, used the perry-dev profile with dev-cli, and let Perry build the runtime features each app needed. That profile only affects how the compiler itself is built; emitted programs still get normal LLVM optimization and the release runtime archives. We also had to install LLVM's libpolly-22-dev.

The first successful Hono build took 435.52 seconds, including the full runtime build, so it isn't a warm compile time. Every throughput number here comes from this source build, with nothing else compiling during measurement.

Whose HTTP implementation is running?

The Node adapter calls node:http, and what that means depends on the runtime. In Perry it reaches a Rust compatibility layer on Hyper and Tokio, which provides the Node-facing API and calls back into the compiled app. See Perry's HTTP server source.

Gea also brings its own. Our node:http is TypeScript compiled by geatsc on top of a native C++ parser and reactor. We compile the real Hono package and its adapter, but not Node's own HTTP implementation. The name describes the API, not where the code came from.

Gea matched all 38 requests in an earlier run; we didn't rerun it here, and its chart rows come from that run. Passing this battery isn't proof of full Node compatibility, and our HTTP implementation lists its remaining deviations.

How we measured

The bench box is an Intel Xeon E3-1231 v3 at 3.40 GHz: four cores, eight logical CPUs. For single-worker runs, each server and its threads were pinned to CPU 0. The load generator, wrk -t4 -c64 over loopback, ran on logical CPUs 4–7. With SMT on, that is logical separation, not guaranteed physical-core isolation.

Each run had a two-second warmup and an eight-second sample per endpoint. We ran three rounds and reversed server and endpoint order in the second. The Hono table uses medians; the chart uses means to match the original. We didn't test TLS, multipart uploads, database work or sustained production traffic.

For four workers, a node:cluster launcher wraps the same raw and Hono handlers, with Hono still on @hono/node-server. Perry's default round-robin scheduling hands connections to four workers. The primary and workers run on CPUs 0–3 and the load generator on 4–7, as in the original comparison. Before each run we traced all 64 connections to their workers through socket descriptors (16 each) and checked that all four workers stayed alive through timing.

Perry runs this unchanged Hono app. On one CPU the tested build served about an eleventh of Node's requests, and it answers differently at the edges. Compiling, answering correctly and running fast are separate questions, and here each got a different answer.