Building the fastest web server in the world
A step-by-step tutorial. You'll write a complete HTTP server on SuperJ's built-in HTTP stack, compile it to a single native binary, run that binary directly, and load-test it with wrk. No framework to install, no runtime to ship — the end product is one executable you can copy anywhere and run.
The finished program ships with SuperJ at demo/sj/demo/WebServer.sj (sj.demo.WebServer) — you can compile that directly (Step 4) or build it up here.
Prerequisites: SuperJ installed (superj --help works, $SJ_HOME set — see Getting Started) and wrk for the benchmark step.
Step 1 — Create the file
SuperJ follows Java's package convention: package sj.demo lives in a sj/demo/ directory. Create sj/demo/WebServer.sj, and start with the package line and the imports we'll need:
package sj.demo;
import sj.http.HttpRequest;
import sj.http.HttpRequestHandler;
import sj.http.HttpSessionProvider;
import sj.lang.Integer;
import sj.net.NioAdapter;
import sj.net.NioConfig;
import sj.net.SessionWriter;
import sj.util.ByteArray;
These come from the SDK — sj.http (HTTP parsing/serving), sj.net (the non-blocking socket adapter), and sj.util.ByteArray (a reusable byte buffer).
Step 2 — Write the request handler
A handler implements HttpRequestHandler.handle(request, writer) — it's called once per HTTP request. We build the 200 OK response once in the constructor and reuse it, so serving a request allocates nothing:
class HelloHandler implements HttpRequestHandler {
private final ByteArray response;
HelloHandler() {
string body = "Hello from SuperJ!\n";
this.response = new ByteArray(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: "
+ body.length() + "\r\n\r\n" + body);
}
public void handle(HttpRequest request, SessionWriter writer) {
this.response.rewind(); // reuse the pre-built buffer
writer.write(this.response);
}
}
Step 3 — Start the server
WebServer.main opens a listen socket and runs an event loop. HttpSessionProvider wires incoming connections through HTTP parsing to your handler; adapter.poll(0) drives the sockets on a single thread:
public class WebServer {
public static void main(String[] args) {
int port = 8080;
if (args.length > 0) { port = Integer.parseInt(args[0]); }
NioConfig cfg = new NioConfig(65536, 16384, 64, 262144, true, 1024);
NioAdapter adapter = new NioAdapter(cfg);
adapter.open("127.0.0.1", port, new HttpSessionProvider(new HelloHandler()));
System.out.println("SuperJ web server listening on http://127.0.0.1:" + port + "/");
while (true) { adapter.poll(0); } // one event loop, one thread
}
}
That's the whole server: a package line, one handler, one main.
Step 4 — Compile it to a native binary
superj compile sj/demo/WebServer.sj --sdk-path "$SJ_HOME/sdk" --link --output webserver
--link produces a finished executable, not just object code. The result is webserver: a single, self-contained native binary — no JVM, no interpreter, no runtime to install alongside it. Copy it to another machine of the same OS/arch and it just runs.
Don't want to type it out? The same program ships with SuperJ — compile the bundled copy:
superj compile "$SJ_HOME/demo/sj/demo/WebServer.sj" --sdk-path "$SJ_HOME/sdk" --link --output webserver.
Step 5 — Run the binary
./webserver 8080
SuperJ web server listening on http://127.0.0.1:8080/
You're running the executable directly — nothing else is involved.
Step 6 — Check it
In another terminal:
curl http://127.0.0.1:8080/
Hello from SuperJ!
Step 7 — Benchmark it with wrk
wrk -t2 -c100 -d30s --latency http://127.0.0.1:8080/
-c100 connections, -d30s duration (wrk's default is 20s — run at least that long, ideally after a short warm-up, for a steady-state number), and --latency for the percentile distribution. Read Requests/sec for throughput, and the Latency percentiles for the tail.
On a Ryzen 9 9950X3D over loopback, this single-file server sustained roughly:
Latency Distribution
50% 156.00us
75% 159.00us
90% 161.00us
99% 167.00us
Requests/sec: 406762
That's the point: p99 sits within ~1.1× of p50 — essentially a flat tail (167µs vs 156µs) — because with no garbage collector there are no pauses to stretch it. Absolute numbers depend on your hardware, NIC, and load generator, so benchmark the box you care about; but you'll be measuring a serving path with nothing between the request and the metal.
But that ~407k is not the server's ceiling — it's wrk's. See the next step.
Step 8 — wrk is the bottleneck: measure with SuperJ's own client
wrk is a single process, and against a server this fast it becomes the bottleneck itself — you end up measuring wrk, not the server. SuperJ ships a native load generator for exactly this: demo/sj/demo/WebClient.sj, an event-loop client on the same built-in stack. It opens keep-alive connections and pipelines requests, so one process drives far more load than wrk can.
Compile it too, then point it at the running server — webclient <port> [connections] [pipeline] [seconds]:
superj compile "$SJ_HOME/demo/sj/demo/WebClient.sj" --sdk-path "$SJ_HOME/sdk" --link --output webclient
./webclient 8080 64 16 15 # 64 connections, 16 pipelined requests each, 15s
On the same box (Ryzen 9 9950X3D, server pinned to one core, load generator to another), a single wrk process topped out around 423k req/s, while one WebClient process pushed the same server to ~515k req/s — the server was never the limit; the client was. Run several WebClient processes (or add pipelining) for still more. Lesson: when you're benchmarking something this fast, check that your load generator isn't the thing you're actually measuring.
Step 9 — Scale across cores
Single-threaded doesn't mean single-core: scale with processes, not threads. The true in NioConfig is reusePort — multiple processes can share the same port, and on Linux the kernel balances incoming connections across them. Run several copies:
for i in 1 2 3 4; do ./webserver 8080 & done
No code change — the same binary, more copies, more cores serving.
Why it's this fast
- Ahead-of-time native — compiled through LLVM to a native binary; no JIT warm-up, full speed from request one.
- No garbage collector — arena memory is reclaimed deterministically, so there are no GC pauses; that's why the latency curve stays flat under load.
- Allocation-free hot path — the response buffer is built once and rewound per request; steady-state serving allocates nothing.
- One event loop, one thread — no locks, no context switches, no cross-core cache traffic on the hot path.
Going further
The same stack does more than plaintext:
- Routing —
sj.http.Routermaps paths/methods to handlers. - Static files —
sj.http.StaticFileHandlerserves a directory with caching. - HTTP/2 —
sj.http.SimpleH2WebServer(prior-knowledge h2c). - WebSockets —
sj.http.WebSocketHandler.
Browse the full HTTP API with superj doc --list (or superj doc sj.http.Router).