DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

My Java Server Was 13x Slower Than It Should Be. The Culprit Was a 40 ms TCP Default (Building Ligero, Part 4)

Here is a benchmark story with a moral. For a while, Ligero's default server engine looked terrible: around 1,000 requests per second with a p50 of 44 ms. The same engine, today, does about 13,000 requests per second with a p50 of 2.8 ms. Nothing about the design changed. A single default did.

This post is about that bug, about how Ligero separates "the framework" from "the HTTP server", and about why I deliberately did not write my own HTTP/2.

The engine SPI

Ligero's core never instantiates a server. Engines implement a tiny SPI and are discovered through ServiceLoader:

public interface ServerEngine {
    void start(EngineConfig config, HttpHandler rootHandler) throws IOException;
    void stop(Duration grace);
    int port();
}
Enter fullscreen mode Exit fullscreen mode

Switching engines is a dependency swap with zero code changes:

// default: the JDK's built-in server on virtual threads, zero dependencies
runtimeOnly 'com.ligeroframework:ligero-server-jdk:<version>'

// or: Jetty 12, which adds HTTP/2 (h2c) and WebSockets
runtimeOnly 'com.ligeroframework:ligero-server-jetty:<version>'
Enter fullscreen mode Exit fullscreen mode
ligero-server-jdk ligero-server-jetty
Dependencies none (JDK) Jetty 12 core
Virtual threads yes yes
gzip yes yes
WebSockets no (fails fast with guidance) yes
HTTP/2 no (HTTP/1.1 only) yes (h2c)

Both engines run the framework's full integration suite, so routing, errors, body limits, redirects and headers behave the same.

The 13x that wasn't a design problem

The default engine wraps com.sun.net.httpserver. In early benchmarks it crawled, and the tempting conclusion was "a zero-dependency server can't compete with Jetty". The numbers said otherwise once I looked at where the time went: ~40 ms per response on keep-alive connections.

That number is a fingerprint. It's the classic interaction of Nagle's algorithm (batch small writes) with delayed ACKs on the other end. com.sun.net.httpserver leaves Nagle on by default, so a response written in two small pieces waits for an ACK that the client is delaying. The fix is a flag, sun.net.httpserver.nodelay, which the engine now sets by default (TCP_NODELAY). Nothing in your application changes.

Same app, same hardware, before and after:

Throughput p50
JDK engine, Nagle on ~1,000 req/s 44 ms
JDK engine, nodelay ~13,000 req/s 2.8 ms

I keep this story in the docs on purpose. It's a good reminder to profile before blaming the design, and to distrust any benchmark you can't explain.

How the comparison is run

The final table comes from a deliberately simple harness (comparison/run.sh plus a zero-dependency, virtual-thread load driver):

  • the same products CRUD app is built in each framework and launched identically (installDist then the start script, no JVM tuning);
  • startup is the median of 4 cold JVM launches, from process exec to first HTTP 200;
  • throughput is GET /products, 50 connections, 8 s, after warm-up;
  • RSS is VmRSS after the load run;
  • one app at a time, medians rather than best-of.
Framework Startup (ms) RSS (MB) Throughput (req/s) p50 (ms) p99 (ms) Dist libs (MB)
Ligero (JDK engine) 429 371 13,049 2.8 13.6 3
Ligero (Jetty engine) 592 262 10,044 3.7 15.1 5
Spring Boot (MVC / Tomcat) 1,952 309 5,809 6.7 29.2 20
Javalin (Jetty) 624 273 10,937 3.1 19.5 8

Read it with the usual caveats: a 4-core machine, JDK 21, one workload, an in-memory store. It measures startup, footprint and raw request handling. It does not measure ecosystem, tooling or maturity, which is where Spring Boot remains the right pick when those outweigh startup, memory and size. The point of publishing the harness is that you can re-run it and disagree with me.

Why I didn't write my own HTTP/2

A fair question: doesn't "pure Java" support HTTP/2 and WebSockets? It splits by client versus server:

HTTP/2 WebSockets
JDK client (java.net.http) yes yes
JDK server (com.sun.net.httpserver) no no

The JDK ships an HTTP/2 and WebSocket client, but its built-in server speaks HTTP/1.1 only. Ligero's JDK engine doesn't implement HTTP at all; it's a thin adapter that delegates the protocol to that class and inherits its limits. Jetty doesn't use that class: it reads raw bytes off NIO channels and implements the protocols itself.

Could I ship my own HTTP/2 engine? Technically, yes. In practice HTTP/2 means implementing, from sockets up:

  • ALPN negotiation in the TLS handshake (the JDK does provide this part via SSLEngine),
  • the binary framing layer and a per-stream state machine,
  • stream multiplexing over one connection,
  • HPACK header compression (stateful, and a classic source of compression-bomb CVEs),
  • per-stream and per-connection flow control.

That's thousands of spec-sensitive, security-critical lines with perpetual maintenance, exactly what a lightweight core exists to avoid. Virtual threads would make the concurrency model pleasant (one virtual thread per stream) but "Loom simplifies the concurrency, not the protocol". So the SPI is the answer: the JDK engine stays zero-dependency for the HTTP/1.1 fast path, and you swap in Jetty's battle-tested stack when you need HTTP/2 or WebSockets.

Writing your own engine

Implement the three methods, map the native request/response to Ligero's HttpRequest/HttpResponse (case-insensitive headers, body-limit enforcement, committed-state tracking), call rootHandler.handle(request, response), and register the class in META-INF/services/com.ligero.spi.ServerEngine. The Jetty adapter is the reference: around 400 lines including WebSockets. For tests, you can inject an engine explicitly with app.engine(new MyEngine()).

Takeaways

  • A 40 ms-per-response signature usually means Nagle plus delayed ACK. Check TCP_NODELAY before you rewrite anything.
  • Keep the protocol stack behind an SPI: zero-dependency fast path by default, a mature implementation one dependency away.
  • Publish the harness, not just the table.

Next: seeing your architecture, with a devtools dashboard that draws your bean graph and lights up each request's path.

Top comments (0)