There are two sets of numbers here, from two tools:
clojure -M:perf), with the server in its own JVM and allocation measured exactly inside it. They track the code; quote these.clojure -M:perf on an Apple M1 Pro laptop (8 CPUs), JDK 25, 10 s per
scenario after 5 s of warm-up, server -Xmx1g, unlimited keep-alive.
Every response is a 200 with the 13-byte body Hello, World!. Server and
load generator share the CPUs, so throughput varies by about 10% between
runs. Allocation per request is stable to a few bytes. Each figure is the
median of three runs, measured on October 11, 2026.
| Scenario | req/s | alloc B/req | p50 ms | p99 ms | p99.9 ms | Load |
|---|---|---|---|---|---|---|
| h1-get | 123,830 | 393 | 0.47 | 0.99 | 5.77 | 64 connections, closed loop |
| h1-get-rate | 19,998 | 396 | 0.24 | 0.38 | 0.71 | open model, 20k req/s over 64 connections, CO-corrected |
| h1-pipelined | 1,384,895 | 235 | 0.17 | 0.53 | 2.07 | 16 connections × depth 16, latency per batch |
| h2-get | 745,127 | 1,090 | 0.26 | 0.71 | – | h2load -c 8 -m 32 over TLS, closed loop |
| h3-get | 73,221 | 801 | 1.74 | 2.70 | 3.03 | 4 QUIC connections × 32 requests in flight, closed loop |
| ws-echo | 1,023,481 | 333 | 0.47 | 0.92 | 3.99 | 64 WebSockets × 8 messages of 128 bytes in flight; one request is one message echoed |
The h2-get and h3-get rows send a tiny request head. A browser sends
more. The h2-get-browser and h3-get-browser scenarios send a 16-field
head (user-agent, accept, accept-language, accept-encoding, cookie,
referer, the four sec-ch-ua* and three sec-fetch-* fields,
upgrade-insecure-requests, dnt, cache-control, pragma) to the same
handler. They run only when named:
clojure -M:perf '{:scenarios [:h2-get-browser :h3-get-browser]}'
On the same laptop, median of three rounds, server and load generator sharing the CPUs, measured on October 12, 2026:
| Scenario | req/s | alloc B/req | p50 ms | p99 ms |
|---|---|---|---|---|
| h2-get-browser | 647,899 | 2,122 | 0.26 | 1.08 |
| h3-get-browser | 55,482 | 2,248 | 1.51 | 3.19 |
A 16-field head fits in a PersistentArrayMap, which now holds up to 32 fields where it used to hold 8. The head allocates about 970 bytes less per request on HTTP/2 and about 900 less on HTTP/3 than the hash map it replaces, measured end to end over three before/after rounds each. Throughput does not drop: looking a name up by scanning the array costs less than building and hashing the map. The small-head rows above are unchanged by the wider array map.
16 MiB POST bodies read to the end by the handler, one connection, one or four requests in flight. These scenarios run only when named:
clojure -M:perf '{:scenarios [:h2-upload :h2-upload-4 :h3-upload :h3-upload-4]}'
On the same laptop, over loopback:
| Scenario | MiB/s | alloc per MiB |
|---|---|---|
| h2-upload (1 stream, TLS) | 1,042 | 117 KiB |
| h2-upload-4 (4 streams, TLS) | 1,077 | 120 KiB |
| h3-upload (1 stream) | 155 | 25 KiB |
| h3-upload-4 (4 streams) | 170 | 30 KiB |
The HTTP/3 figures are limited by the load client (s-exp.h3-load), not
the server.
Receive windows start small and grow while the handler keeps up, so
round-trip time matters more than loopback shows. With 50 ms of added
round-trip time (tc netem in a 2-CPU Linux container, so not comparable
with the table above):
| Scenario | MiB/s |
|---|---|
| h2-upload | 34 |
| h2-upload-4 | 61 |
| h3-upload | 32 |
| h3-upload-4 | 117 |
With windows fixed at their initial sizes, as with a stock libquiche, the HTTP/3 figures drop to 2.7 and 5.3 MiB/s.
Allocation is everything the server JVM allocated during the measured window, divided by the requests completed. It includes the Ring request map, the handler's response map, TLS and the JDK's virtual thread bookkeeping. What the request path itself allocates is broken down in architecture.md, where tests hold each path to a bound.
The allocation gate (clojure -M:perf-gate, see
testing.md) compares a run against the
per-platform baselines in bench/perf/baseline.edn.
The harness lives in s-exp.enso-perf.
The server (s-exp.enso-test-server) runs in its own JVM, -Xmx1g by
default (:server-jvm-opts to change it). The load generator runs in the
harness JVM. The two still share the host's CPUs; on Linux, pin them apart
(with taskset, for example) for less variance.
Allocation is measured in the server process, not sampled. It is the
change in ThreadMXBean#getTotalThreadAllocatedBytes across the measured
window, which covers every thread including the carriers of virtual
threads (checked by allocating a known amount on a virtual thread). Warm-up
traffic is excluded. Since it counts everything the server JVM allocated
(parser, handler adapter, response writer, TLS, GC bookkeeping), it is an
upper bound for enso's own code.
h1-get-rate is an open-model test. Each connection sends on a fixed
schedule and latency is measured from the intended send time, so a server
stall shows up in the percentiles instead of silently lowering the
request rate (coordinated-omission correction, as in wrk2). The other
scenarios are closed-loop throughput tests; their percentiles are
reported but not CO-corrected.
h2-get uses h2load, which reports median and p99 from nghttp2 1.62 on, and only min, max and mean before.h3-get drives :h3-connections (4) QUIC connections, each keeping :h3-in-flight (32) requests open, from a lean load client (s-exp.h3-load: quiche through the enso shim, one platform thread per connection).ws-echo keeps :ws-depth (8) text messages of :ws-message-bytes (128) in flight on each connection against the echo endpoint. A request is one message read, dispatched and written back, so its allocation includes the message String and the echo's encoded bytes. Part of it is the JDK's: a virtual thread parking in a socket read with a timeout (every read here, bounded by :idle-timeout) allocates a scheduler task, so a server that drains each batch sooner parks, and allocates, more often.Results go to target/perf/latest.{edn,json,md}, plus a timestamped
copy: git revision, JVM, OS, CPU count, configuration, and per scenario
the request count, req/s, bytes allocated per request, GC count and time,
and percentiles. The nightly CI job uploads them as artifacts. Shared
runners are noisy, so treat those as a trend line.
clojure -M:perf # all scenarios
clojure -M:perf '{:duration-s 30 :scenarios [:h1-get-rate] :rate 50000}'
clojure -M:bench/compare runs the scenarios below against Ensō and other
JVM servers. Every server runs in its own JVM, started from its own deps
alias, on the same JDK (Oracle GraalVM 25.0.4) with the same flags
(-Xmx1g, nothing else). The load generators run in other processes.
Allocation is measured inside each server JVM, as described in
Methodology. Each figure below is the median of three
rounds, with the lowest and highest round as the range.
Measured in October 2026 at revision 8100538, on the same laptop as the current numbers.
| Server | Version | Configuration |
|---|---|---|
| Ensō | 8100538 | the perf test server, defaults, unlimited keep-alive requests; handlers on virtual threads |
| http-kit | 2.8.1 | defaults: 4 worker threads |
| Jetty | 12.1.14, through ring-jetty9-adapter 0.40.5 | adapter defaults: a pool of 8 to 50 platform threads |
| Jetty, virtual threads | same | :virtual-threads? true |
| Aleph | 0.9.11 (Netty 4.1.137) | defaults: handlers on Aleph's executor (up to 512 threads), NIO |
| Netty (HTTP/3 only) | 4.2.19 HTTP/3 codec and native QUIC | one event loop; flow-control windows and stream limits set to Ensō's defaults, since Netty's are zero |
Jetty appears twice because its default platform thread pool and its virtual thread option perform quite differently, and both are common in production.
Every server answers GET / with a 200 and the 13-byte body
Hello, World!, content-type: text/plain. Servers add their own
headers: all send Date, http-kit, Jetty and Aleph also send Server,
and Aleph adds a charset and Connection: Keep-Alive. Before measuring
each scenario, the harness fetches one response over that protocol and
checks the status and the body. Load generators count failed requests and
non-2xx responses. Every cell below had zero of both, in every round.
TLS (HTTP/2) uses the JDK's provider and the same self-signed RSA 2048 certificate on Ensō, Jetty and Aleph. All three HTTP/3 servers use quiche: Ensō through its JNI shim, Netty through its own JNI build, Jetty through FFM.
wrk with 4 threads. Non-pipelined: 64 connections. Pipelined: 16 connections with 16 requests per batch, sent by a wrk script. wrk's percentiles are not reliable with pipelining (it reports a p99 of 0), so only throughput is given there.
Non-pipelined:
| Server | req/s | range | alloc B/req | p50 ms | p99 ms |
|---|---|---|---|---|---|
| Ensō | 134,415 | 133,207–135,055 | 393 | 0.36 | 1.62 |
| Jetty, virtual threads | 129,374 | 129,073–129,796 | 4,030 | 0.32 | 7.28 |
| http-kit | 127,989 | 126,890–128,889 | 3,604 | 0.43 | 0.97 |
| Jetty | 119,730 | 119,303–119,890 | 3,583 | 0.28 | 6.79 |
| Aleph | 86,406 | 86,227–86,812 | 3,623 | 0.33 | 121 |
Pipelined, depth 16:
| Server | req/s | range | alloc B/req |
|---|---|---|---|
| Ensō | 1,734,320 | 1,724,285–1,737,618 | 235 |
| http-kit | 378,682 | 369,854–381,492 | 4,345 |
| Jetty | 352,036 | 350,025–354,434 | 3,481 |
| Jetty, virtual threads | 343,600 | 342,380–345,307 | 3,465 |
| Aleph | 71,149 | 70,854–71,628 | 3,264 |
Without pipelining, all servers fall between 120k and 135k req/s except Aleph. Ensō has the second lowest p99, after http-kit. It allocates 393 bytes per request, where the others allocate 3,600 to 4,000.
h2load over TLS, the h2-get settings: -c 8 -m 32 -t 2. http-kit has no
HTTP/2 server.
| Server | req/s | range | alloc B/req | p50 ms | p99 ms |
|---|---|---|---|---|---|
| Ensō | 855,236 | 837,785–875,238 | 1,138 | 0.21 | 0.89 |
| Jetty, virtual threads | 408,110 | 407,115–408,454 | 8,045 | 0.43 | 3.11 |
| Jetty | 158,581 | 157,211–160,573 | 6,383 | 1.14 | 7.51 |
| Aleph | 38,005 | 30,139–38,272 | 14,471 | 4.20 | 56.53 |
The h3-get scenario for every server: s-exp.h3-load, 4 QUIC
connections with 32 requests in flight each. The client checks the status
of every response. http-kit and Aleph have no HTTP/3 server.
| Server | req/s | range | alloc B/req | p50 ms | p99 ms |
|---|---|---|---|---|---|
| Ensō | 72,854 | 71,606–73,586 | 797 | 1.78 | 2.72 |
| Netty | 64,551 | 61,532–65,599 | 4,084 | 1.89 | 2.75 |
| Jetty | 23,666 | 23,576–23,753 | 10,444 | 3.93 | 41.88 |
| Jetty, virtual threads | 9,610 | 9,470–9,687 | 15,146 | 12.80 | 21.59 |
Ensō is 13% faster than Netty, with the same p99. The load client runs one platform thread per connection and may limit both. That was not measured separately.
Jetty's HTTP/3 connector is set up as the Jetty 12.1 documentation shows
(HTTP3ServerQuicConfiguration and HTTP3ServerConnectionFactory) and
serves the same Ring handler. The adapter's own :http3? option does not
answer requests in these versions: it installs a raw HTTP/3 connection
factory with an empty session listener and keeps Jetty's QUIC defaults of
zero unidirectional streams.
The ws-echo scenario: 64 WebSockets, 8 messages of 128 bytes in flight
on each. One request is one message echoed.
| Server | msg/s | range | alloc B/msg | p50 ms | p99 ms |
|---|---|---|---|---|---|
| Ensō | 1,024,862 | 1,009,962–1,026,881 | 334 | 0.47 | 0.98 |
| http-kit | 533,613 | 531,403–536,963 | 892 | 0.93 | 1.68 |
| Jetty | 379,908 | 379,723–380,306 | 5,229 | 0.81 | 6.84 |
| Jetty, virtual threads | 373,828 | 372,964–374,610 | 5,290 | 1.02 | 8.92 |
| Aleph | 48,292 | 45,444–48,942 | 2,655 | 8.33 | 49.84 |
Ensō echoes 1.9 times as many messages per second as http-kit, at half the latency, and allocates less per message. The Ensō and http-kit rows come from a separate run of the same harness, 3 rounds.
Most of the cost here is in the kernel, one write per TCP segment. Ensō
writes the replies to the messages it has read in one write. http-kit
writes each reply on its own but leaves Nagle's algorithm on, so the
kernel joins its small writes into fewer segments. Ensō sets
TCP_NODELAY (:so-nodelay).
The echo handlers differ by server. Ensō uses its WebSocketSocket send
methods, Jetty the Ring WebSocket API (ring.websocket/send), http-kit
as-channel and send!, and Aleph a Manifold stream connected to itself.
s-exp.h3-load and the WebSocket client are closed-loop:
each connection waits for responses before sending more. Their
percentiles are not corrected for coordinated omission.clojure -M:bench/compare
clojure -M:bench/compare '{:rounds 5 :servers [:enso :jetty] :scenarios [:h2-get]}'
Needs wrk, h2load and quiche-client (for the HTTP/3 response check)
on the PATH, and the Ensō quiche shim. The driver is
bench/enso/compare.clj. The peer servers are under bench/enso/peer/,
each with its own deps alias (:bench/http-kit, :bench/jetty,
:bench/aleph, :bench/netty), so that Aleph's Netty 4.1 and Netty 4.2
never share a classpath. Results go to target/compare/summary.{edn,md},
along with each run's own perf results and the load average for each run.
The Netty dependency lists native QUIC builds for macOS and Linux on
x86_64 and aarch64.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |