Liking cljdoc? Tell your friends :D

Performance

There are two sets of numbers here, from two tools:

  • Current numbers come from the regression harness (clojure -M:perf), with the server in its own JVM and allocation measured exactly inside it. They track the code; quote these.
  • Comparison with other servers runs the same harness against other JVM servers, each in its own JVM, so allocation is measured the same way for all of them.

Current numbers

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.

Scenarioreq/salloc B/reqp50 msp99 msp99.9 msLoad
h1-get123,8303930.470.995.7764 connections, closed loop
h1-get-rate19,9983960.240.380.71open model, 20k req/s over 64 connections, CO-corrected
h1-pipelined1,384,8952350.170.532.0716 connections × depth 16, latency per batch
h2-get745,1271,0900.260.71–h2load -c 8 -m 32 over TLS, closed loop
h3-get73,2218011.742.703.034 QUIC connections × 32 requests in flight, closed loop
ws-echo1,023,4813330.470.923.9964 WebSockets × 8 messages of 128 bytes in flight; one request is one message echoed

Browser-sized request heads

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:

Scenarioreq/salloc B/reqp50 msp99 ms
h2-get-browser647,8992,1220.261.08
h3-get-browser55,4822,2481.513.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.

Uploads

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:

ScenarioMiB/salloc per MiB
h2-upload (1 stream, TLS)1,042117 KiB
h2-upload-4 (4 streams, TLS)1,077120 KiB
h3-upload (1 stream)15525 KiB
h3-upload-4 (4 streams)17030 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):

ScenarioMiB/s
h2-upload34
h2-upload-461
h3-upload32
h3-upload-4117

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.

Methodology

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}'

Comparison with other servers

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.

Servers

ServerVersionConfiguration
Ensō8100538the perf test server, defaults, unlimited keep-alive requests; handlers on virtual threads
http-kit2.8.1defaults: 4 worker threads
Jetty12.1.14, through ring-jetty9-adapter 0.40.5adapter defaults: a pool of 8 to 50 platform threads
Jetty, virtual threadssame:virtual-threads? true
Aleph0.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 QUICone 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.

HTTP/1.1

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:

Serverreq/srangealloc B/reqp50 msp99 ms
Ensō134,415133,207–135,0553930.361.62
Jetty, virtual threads129,374129,073–129,7964,0300.327.28
http-kit127,989126,890–128,8893,6040.430.97
Jetty119,730119,303–119,8903,5830.286.79
Aleph86,40686,227–86,8123,6230.33121

Pipelined, depth 16:

Serverreq/srangealloc B/req
Ensō1,734,3201,724,285–1,737,618235
http-kit378,682369,854–381,4924,345
Jetty352,036350,025–354,4343,481
Jetty, virtual threads343,600342,380–345,3073,465
Aleph71,14970,854–71,6283,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.

HTTP/2

h2load over TLS, the h2-get settings: -c 8 -m 32 -t 2. http-kit has no HTTP/2 server.

Serverreq/srangealloc B/reqp50 msp99 ms
Ensō855,236837,785–875,2381,1380.210.89
Jetty, virtual threads408,110407,115–408,4548,0450.433.11
Jetty158,581157,211–160,5736,3831.147.51
Aleph38,00530,139–38,27214,4714.2056.53

HTTP/3

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.

Serverreq/srangealloc B/reqp50 msp99 ms
Ensō72,85471,606–73,5867971.782.72
Netty64,55161,532–65,5994,0841.892.75
Jetty23,66623,576–23,75310,4443.9341.88
Jetty, virtual threads9,6109,470–9,68715,14612.8021.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.

WebSocket

The ws-echo scenario: 64 WebSockets, 8 messages of 128 bytes in flight on each. One request is one message echoed.

Servermsg/srangealloc B/msgp50 msp99 ms
Ensō1,024,8621,009,962–1,026,8813340.470.98
http-kit533,613531,403–536,9638920.931.68
Jetty379,908379,723–380,3065,2290.816.84
Jetty, virtual threads373,828372,964–374,6105,2901.028.92
Aleph48,29245,444–48,9422,6558.3349.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.

Caveats

  • Server and load generator share one laptop's 8 CPUs. A server that needs less CPU leaves more for the client, which helps it in every closed-loop scenario.
  • wrk, h2load, 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.
  • Allocation covers the whole server JVM during the measured window (adapter, server, TLS, the JDK's own bookkeeping), so it includes work each adapter does to build the Ring request map.
  • Before each server run, the driver waited for the 1-minute load average to drop below 4. Every run started between 3.35 and 3.98. The 5-minute average stayed between 4 and 8.6 because of the benchmark itself. An editor and a browser stayed open during the runs.
  • The WebSocket client opens its connections one at a time. With 64 simultaneous connects, http-kit's listen backlog (50, the JDK default, which it does not let you change) overflows and macOS resets the extra connections.

Reproduce

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

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close