Automated benchmarking framework for comparing java-http against other Java HTTP server implementations.
- Java 25+ with
JAVA_HOMEset (e.g.,export JAVA_HOME=/opt/homebrew/opt/openjdk@25). Theselfserver depends on the rootorg.lattejava:httpbuild, which targets Java 25 bytecode. - Latte build tool (
latteon PATH) - wrk HTTP benchmark tool (
brew install wrkon macOS) - jq for JSON processing (
brew install jqon macOS) - h2load (optional) for HTTP/2 scenarios (
brew install nghttp2on macOS). The runner skipsh2-*scenarios gracefully when h2load is absent.
# Set the JAVA_HOME (this assumes you have the javaenv shell helper installed)
javaenv shell 25
# Run all servers, all scenarios, default 30s duration
./run-benchmarks.sh
# Quick smoke test
./run-benchmarks.sh --servers self --scenarios hello --duration 5s| Server | Directory | Description |
|---|---|---|
self |
self/ |
java-http (the project being benchmarked) |
jdk-httpserver |
jdk-httpserver/ |
JDK built-in com.sun.net.httpserver.HttpServer |
jetty |
jetty/ |
Eclipse Jetty 12.0.x embedded server |
netty |
netty/ |
Netty 4.1.x with HTTP codec |
tomcat |
tomcat/ |
Apache Tomcat 11.0.x embedded |
All servers implement the same endpoints on port 8080:
GET /- No-op (reads body, returns empty 200 response)GET /no-read- No-op (does not read body, returns empty 200 response)GET /hello- Returns "Hello world"GET /file?size=N- Returns N bytes of generated content (default 1MB)POST /load- Base64-encodes request body and returns it
./run-benchmarks.sh [OPTIONS]
Options:
--servers <list> Comma-separated server list (default: all)
--scenarios <list> Comma-separated scenario list (default: all)
--label <name> Label for the results file
--output <dir> Output directory (default: benchmarks/results/)
--duration <time> Duration per scenario (default: 30s)
-h, --help Show this help
# All servers, hello scenario only, 10s
./run-benchmarks.sh --scenarios hello --duration 10s
# Compare self vs netty
./run-benchmarks.sh --servers self,netty --scenarios hello,baseline --duration 10s
# Full suite with a label (results saved as results/2026-02-17-m4-full.json)
./run-benchmarks.sh --label m4-full --duration 30s
# Single server quick check
./run-benchmarks.sh --servers self --scenarios hello --duration 5s| Scenario | Endpoint | Threads | Connections | Purpose |
|---|---|---|---|---|
baseline |
GET / |
12 | 100 | No-op throughput ceiling |
hello |
GET /hello |
12 | 100 | Small response body |
post-load |
POST /load |
12 | 100 | POST with body, Base64 response |
large-file |
GET /file?size=1048576 |
4 | 10 | 1MB response throughput |
high-concurrency |
GET / |
12 | 1000 | Connection pressure |
mixed |
Rotates endpoints | 12 | 100 | Real-world mix |
browser-headers |
GET / |
12 | 100 | Realistic browser header set |
h2load uses h2c prior-knowledge (plaintext HTTP/2 without an upgrade handshake). The canonical h2 workload is few TCP connections × many concurrent streams, which has no direct h1.1 equivalent.
| Scenario | Endpoint | Threads | TCP Connections | Streams/conn | Total in-flight | Purpose |
|---|---|---|---|---|---|---|
h2-hello |
GET /hello |
1 | 1 | 100 | 100 | Baseline h2 throughput — single connection, many streams |
h2-high-stream-concurrency |
GET /hello |
4 | 10 | 100 | 1000 | Many-streams-per-conn (backend / proxy shape) — favors event-loop demux |
h2-high-connection-concurrency |
GET /hello |
4 | 500 | 2 | 1000 | Many-conns-few-streams (browser / CDN shape) — same in-flight, different topology |
h2-compute |
GET /compute?rounds=5000 |
4 | 10 | 100 | 1000 | CPU-bound — chained SHA-256 ~500us–1ms/req; protocol becomes <20% of cost |
h2-io |
GET /io?ms=10 |
4 | 10 | 100 | 1000 | Blocking-IO simulation — 10ms sleep per request; tests thread/IO model under wait |
h2-stream |
GET /stream?size=131072 |
4 | 10 | 100 | 1000 | 128KB response, handler forces per-8KB flush — tests honor-flush wire path |
h2-large-response |
GET /large-response?size=131072 |
4 | 10 | 100 | 1000 | 128KB response, handler writes once — server chooses framing |
Each h2 scenario was chosen to stress a specific axis of server design. The number that comes out is meaningful only in the context of the workload it was meant to expose — there is no single "h2 throughput" measurement.
-
h2-hello— baseline. Tiny request, tiny response, one TCP connection, 100 multiplexed streams. Measures the per-stream + per-frame overhead with all batching effects intact and zero application work. Useful as a sanity / warmup number, not as a peer-comparison headline. -
h2-high-stream-concurrency(10 conn × 100 streams) — the canonical h2 multiplexing showcase. Maps to backend service-to-service traffic where a small pool of pinned HTTP/2 connections carries lots of concurrent requests, or to reverse-proxy / API-gateway shapes. This is Netty's home field: a single event-loop thread demuxes 100 streams per socket inline, which is the exact pattern itsHttp2MultiplexHandlerwas built for. Worker-pool servers (Tomcat, Jetty) pay per-stream dispatch cost; Latte pays per-stream virtual-thread mount cost. -
h2-high-connection-concurrency(500 conn × 2 streams) — inverse topology, same 1000 in-flight. Maps to browser-facing or CDN traffic, where each end-user maintains one or a few connections and the server sees lots of distinct sockets. Tests accept-loop throughput, connection-state bookkeeping, and the kernel's pending-SYN backlog. Tomcat is structurally disadvantaged here because its connection per-thread / per-worker model doesn't scale to many concurrent sockets the way a virtual-thread or event-loop model does. Latte's virtual-thread-per-connection design and Netty's event-loop both handle this shape cleanly; both also depend onSO_BACKLOGbeing above the connection count. -
h2-compute(chained SHA-256 × 5000 rounds, ~500µs–1ms CPU per request) — protocol-overhead-stress-test inverted. By making the handler genuinely CPU-bound, this scenario reduces the protocol stack to <20% of per-request cost, which means all servers should converge near the CPU-bound ceiling (~6–10k RPS for a single core × ms-scale work, scaled by core count). Differences here largely reflect how much fixed overhead each server adds on top of the actual work — Latte and Tomcat lose ground to Netty proportional to their per-request protocol cost. Useful as a "what does this server do in a real app" reading. -
h2-io(Thread.sleep(10ms)) — simulates a downstream call (DB query, cache lookup, microservice fetch). Architectural model is everything here. Latte's virtual threads park essentially for free; Netty'sctx.executor().schedule()schedules the response asynchronously without blocking the event loop; Tomcat and Jetty pay their worker-pool size as a hard ceiling — at default 200 worker threads and 10ms sleep, theoretical max throughput is 20k RPS regardless of CPU, network, or anything else. This scenario maps most directly to "what happens when an app handler waits for IO," which is what real apps do constantly. Tomcat / Jetty numbers here will jump if you increase their worker-pool sizes; the architectural ceiling does not. -
h2-stream(128KB body, handler forces per-8KBflush()) — tests the honor-flush wire path. The handler explicitly writes 16 × 8KB chunks withOutputStream.flush()between each. Latte and Jetty honorflush()literally — each call drains the buffer into a DATA frame and enqueues it for the wire. Tomcat treats servletflush()as a hint and largely ignores it; Netty's bench handler sends aFullHttpResponseand lets the codec fragment. So this scenario partially measures wire-level fidelity to handler intent, not raw throughput. The right baseline for "honor-flush throughput" is Latte and Jetty; the Tomcat / Netty numbers here are a "what they do when asked to chunk" reading. -
h2-large-response(128KB body, singlewrite()) — counterpart toh2-stream. Handler writes the entire body once and letsclose()push it to the wire; the server chooses how to fragment into DATA frames (HTTP/2MAX_FRAME_SIZEtypically 16KB). This is what most "large response" endpoints actually look like in practice (file downloads, JSON dumps, response bodies built from a buffer). Netty'sh2-large-responseshould match itsh2-stream(it usesFullHttpResponsefor both); the gap betweenh2-streamandh2-large-responsefor Latte / Jetty quantifies the cost of honoring per-chunk flush.
The benchmark LoadHandler is implemented separately for each server (in benchmarks/self/, benchmarks/netty/, benchmarks/jetty/, benchmarks/tomcat/) using each server's idiomatic API. A few asymmetries are deliberate and worth knowing:
-
Latte / Tomcat / Jetty call
Thread.sleep(ms)directly for/io. Latte parks the virtual thread; Tomcat / Jetty park a worker-pool thread. Netty cannot useThread.sleep— that would block the event loop and stall every stream on the channel. Netty usesctx.executor().schedule()to dispatch the response asynchronously. This is the fair Netty-idiomatic equivalent; production Netty code would do the same. -
/streamchunked write: Latte and Jetty actually emit per-chunk DATA frames on the wire. Tomcat's servletflush()is a hint the container can ignore; for the 8KB chunks in our/streamhandler Tomcat likely coalesces. Netty's handler doesn't chunk at all — it sendsFullHttpResponseand the HTTP/2 codec fragments into MAX_FRAME_SIZE-bounded DATA frames automatically. -
/computeon Netty runs on the event loop thread. CPU-heavy work in a Netty handler is normally offloaded to an executor in production, but for this benchmark we keep it on the event loop (simplest implementation; matches the rest of the handler). The 500µs–1ms of SHA work doesn't starve other streams long enough to matter for throughput, but production Netty deployments with longer handlers would offload. Latte / Tomcat / Jetty don't have this concern — their handlers are blocking-style by design. -
HttpObjectAggregator(10 MB)on Netty (h2c + TLS pipelines) means the request body is fully buffered before the handler runs. For the bodyless GETs in our scenarios this is free, but it does add a setup cost not present in Latte's handler. -
JVM args for each server come from each
benchmarks/<vendor>/build/dist/start.sh(orcatalina.shfor Tomcat). Heap / GC / virtual-thread settings are intentionally left at each server's default rather than uniformly tuned — the point of the comparison is "what does this server do out of the box," not "after careful tuning." Numbers may move 10–25% with vendor-specific tuning (Tomcat in particular benefits from a largermaxThreadsforh2-io).
Per-vendor h2c support:
| Server | HTTP/2 support | Notes |
|---|---|---|
self (Latte) |
h2c prior-knowledge on port 8080 | Enabled in the HTTP/2 implementation branch |
jetty |
h2c prior-knowledge on port 8080 | HTTP2CServerConnectionFactory alongside HttpConnectionFactory on the same ServerConnector |
tomcat |
h2c prior-knowledge + Upgrade on port 8080 | <UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol"/> in server.xml; Tomcat 11 handles both the PRI * preface and the Upgrade: h2c header |
netty |
h2c prior-knowledge + Upgrade on port 8080 | CleartextHttp2ServerUpgradeHandler detects h2c preface, h1.1 Upgrade, or plain h1.1 — all on one port |
jdk-httpserver |
HTTP/1.1 only — h2-* scenarios skipped | com.sun.net.httpserver.HttpServer has no HTTP/2 support; run-benchmarks.sh skips h2-* scenarios for this server |
Results are written as JSON to benchmarks/results/ with an ISO timestamp format:
results/YYYY-MM-DDTHH-MM-SSZ.json
results/YYYY-MM-DDTHH-MM-SSZ-<label>.json
Use the compare script to compare two result files:
./compare-results.sh results/2026-02-17T20-30-00Z-baseline.json results/2026-02-17T21-00-00Z-after-change.jsonThis shows normalized ratios to identify performance regressions or improvements.
To update the main project README with the latest benchmark results:
./update-readme.shThis reads the most recent JSON from results/ and replaces the ## Performance section in the project root README.md.
run-benchmarks.sh answers "how do we compare to Jetty/Netty/Tomcat/Helidon/Undertow?". For
the different question — "did my change to self actually help?" — use
perf-test.sh. It runs wrk against self only, attaches a JFR recording to
the JVM, and emits a single JSON file with both wrk metrics (RPS, latency,
errors) and JFR metrics (allocation rate, GC pauses, peak heap).
./perf-test.sh # 3 trials × 30s, browser-headers scenario
./perf-test.sh --scenario hello --duration 10s # tighter loop
./perf-test.sh --baseline perf-results/<earlier>.json # diff inline at end
./perf-test.sh --detailed # add hot methods + alloc sites
./compare-perf.sh perf-results/A.json perf-results/B.json # diff two existing runs- Check out
main(or the parent of the change you're testing). - Run a baseline:
./perf-test.sh --label before
- Apply the change.
- Run again with the baseline pinned:
./perf-test.sh --label after --baseline perf-results/<timestamp>-before.json
- Read the inline
=== perf-test delta ===table. Improvements are green, regressions red (when stdout is a TTY).
Each run produces perf-results/<timestamp>[-label].json with this shape:
summary— median/min/max for nine metrics across all trials.trials_raw— per-trial wrk + JFR records, plus the per-trial.jfrpath.system,git— machine info and the commit being tested (with adirtyflag set when the working tree has uncommitted changes).detailed—nullunless--detailedwas passed; then an object withhot_methods(top 20 by sample count) andalloc_sites(top 20 by allocation event count) drawn from the median-RPS trial.
| Metric | Direction | What it tells you |
|---|---|---|
rps |
higher = better | Observable throughput at the wrk client. The headline. |
avg_latency_us |
lower = better | Mean per-request latency at the wrk client. |
p99_us |
lower = better | Tail latency. More sensitive to GC pauses than avg_latency_us. |
errors |
lower = better | Sum of wrk's connect/read/write/timeout error buckets. Should be 0 on a healthy run. |
alloc_bytes_per_req |
lower = better | The closest proxy for "did this change reduce allocations." Normalises by load, so it's stable across runs at slightly different RPS. |
alloc_bytes_per_sec |
lower = better | Raw allocation rate. Useful sanity-check; biased by RPS. |
gc_pause_ms_total |
lower = better | Total time the JVM spent in GC during the recording. Drops when allocation pressure drops. |
gc_count |
lower = better | Number of collections. |
heap_peak_mb |
lower = better | Worst-case heap (max heap_before_gc). Catches regressions that grow working set even when alloc rate stays flat. |
Direction notes:
- A good change typically moves
alloc_bytes_per_reqandgc_pause_ms_totaldown together, and pullsrpsup andp99_usdown. Ifrpsrises but allocations don't move, the JIT may have figured something out independently of the change — consider--detailedto confirm where time and allocations are now flowing. errorsjumping above 0 invalidates the run; investigate the server log before trusting any of the other deltas.
Each trial's JFR file is kept under perf-results/<timestamp>[-label]/trial-<n>.jfr.
Open it in JDK Mission Control for richer views
(thread states, lock contention, escape-analysis hints, hot paths). The numeric
summary is enough for go/no-go on a change; JMC is for "where do I attack next."
jfr summary perf-results/<timestamp>/trial-1.jfr
jfr print --events jdk.ExecutionSample --stack-depth 5 \
perf-results/<timestamp>/trial-1.jfr | head -40
jfr print --events jdk.ObjectAllocationSample --stack-depth 3 \
perf-results/<timestamp>/trial-1.jfr | head -40Each server can be built independently using Latte:
# Most servers
cd benchmarks/<server> && latte clean app
# Tomcat (different target)
cd benchmarks/tomcat && latte clean tomcat
# Start a server manually
cd benchmarks/<server>/build/dist && ./start.sh