mojo_net
A networking stack for Linux in Mojo, from raw syscalls to an HTTP server. Written by an AI, as an experiment.
An AI-generated, play-like experiment. Not a product.
The code, tests, documentation and this page were written by an AI (Claude, through Claude Code), at the direction of one person who chose what to build, in what order, and what not to do. The question was simply how far an AI agent could take a networking stack in a young language. Not all of the code has been read by a human. The TLS code has been reviewed only by AI reviewers, never by a human expert.
Don't use it in production. No package is published. The repository is public, under Apache-2.0, for reading and curiosity.
One laptop, over loopback, on 28 October 2026. The method is below, in full.
What it is
Mojo is a young language from Modular, Python-like in syntax and compiled to native code. Its standard library has little networking, and its async/await is not stable yet. mojo_net builds the missing layers for Linux on x86_64 and aarch64:
linux: every syscall as inline assembly, with no libc on the I/O path; ABI structs checked against the C headers; epoll, signals through signalfd, and processes through clone3 and pidfds.net: TCP, UDP and Unix sockets with deadlines, a DNS resolver of its own, and connect-by-name with Happy Eyeballs.tls: TLS 1.2 and 1.3 through OpenSSL 3.5, client and server. TLS itself was never written here: OpenSSL does the protocol, and this layer only drives it over memory buffers.http: an HTTP/1.1 parser and connection codecs that do no I/O themselves, server-sent events, and a blocking client forhttp://andhttps://.server: an HTTP/1.1 server with timeouts, backpressure, streaming, HTTPS, a router, static files, graceful shutdown and supervised worker processes.- Also: a PostgreSQL driver, a Redis client, strict JSON, the hashes and MACs that database logins need, and clients for model APIs, all on the same layers.
How it was made
Between 22 and 29 October 2026, in about 75 commits: about 45,500 lines of Mojo in the libraries and 55,000 in the tests (166 test files).
- Direction: one person set the goals and the order of work, and made the calls: TLS only through OpenSSL; no async runtime until Mojo's async is stable; nothing presented as production-ready.
- Writing: an AI coding agent planned each step, wrote the code, tests and documentation, ran the checks and committed.
- Reviews: separate AI reviewer agents read each library in turn, as an attacker, against the RFCs and against how Go, nginx, hyper and Kestrel behave. TLS, TCP/UDP and HTTP each had three rounds, the server two, and PostgreSQL one round with three reviewers. Every finding was first reproduced by a test that fails on the old code, then fixed.
- Checking the tests: each fix was then undone again, one at a time, on a copy of the code, to see whether its test notices. In the latest server review, all 62 of these planted bugs were caught, three of them only after their tests were made sharper.
- Independent references: outputs are compared with other implementations: Python's
ipaddress,json,hashlibandhttp.server, OpenSSL's own tools, a real PostgreSQL and Valkey, and the x509-limbo corpus for certificate checks. - CI: every push runs all of it on x86_64 and on a real aarch64 machine, with the TLS tests also under AddressSanitizer.
The repository's README carries the same warning as this page. Its docs record the decisions, the reviews and what each one found.
Results: the HTTP server against hyper, fasthttp, nginx and Go
This is TechEmpower's plaintext test: GET /plaintext answered with Hello, World!. Each server runs in its fastest usual form, measured with wrk on one laptop (an Intel i5-1145G7, 4 cores and 8 threads) on 28 October 2026. Requests per second are the mean of two 60-second runs.
What it shows:
- Pipelined (16 requests per write), mojo takes 0.84 µs of CPU per request against hyper's 0.83 and fasthttp's 0.88. On four CPUs it is ahead: 2.15M requests/s against 2.07M.
- Without pipelining, hyper, mojo, fasthttp and nginx are within a few percent of each other, and the runs' ranges overlap. Most of each request's time there is the kernel's loopback TCP, which is the same for every server. In the quieter of the two one-CPU runs, mojo and hyper were even: 183,059 and 183,042 requests/s.
- On four CPUs without pipelining, wrk itself used 87–92% of its CPU, so those numbers mostly measure the client.
- Go's net/http takes about twice the CPU per request of the others, and gains little from pipelining: it writes each response separately. fasthttp, in the same language, is in the top group.
A day earlier
On 27 October, before the HTTP and server reviews, mojo was 35% behind hyper pipelined. The reviews' profile-driven changes moved it to the numbers above. hyper's own numbers stayed the same within noise:
| Scenario | mojo, 27 Oct | mojo, 28 Oct | change | hyper, 27 / 28 Oct |
|---|---|---|---|---|
| 1 CPU, 64 keep-alive connections | 156k | 168k | +8% | 181k / 172k |
| 1 CPU, 256 connections, 16 pipelined | 762k | 1.19M | +56% | 1.17M / 1.19M |
| 1 CPU, 1024 keep-alive connections | 132k | 144k | +9% | 144k / 146k |
| 4 CPUs, 256 keep-alive connections | 301k | 350k | +16% | 350k / 340k |
| 4 CPUs, 256 connections, 16 pipelined | 1.33M | 2.15M | +62% | 2.12M / 2.07M |
Every number
CPU per request is the server's user plus system time divided by the requests answered. Mean latency comes from Little's law: connections × requests per write ÷ requests per second. For the pipelined rows it is per batch of 16. The p50 and p99 are wrk's, and are given only without pipelining (see caveats). The raw data, with wrk's own output for every run, is in the repository.
1 CPU, 64 keep-alive connections
| Server | req/s (both runs) | CPU per request | wrk CPU | Mean latency | p50 | p99 |
|---|---|---|---|---|---|---|
| hyper | 172k (162k, 183k) | 5.77 µs | 41% | 373 µs | 360 µs | 612 µs |
| mojo | 168k (153k, 183k) | 5.94 µs | 40% | 384 µs | 368 µs | 749 µs |
| fasthttp | 162k (154k, 169k) | 6.16 µs | 37% | 397 µs | 398 µs | 992 µs |
| nginx | 156k (142k, 171k) | 6.40 µs | 37% | 413 µs | 394 µs | 764 µs |
| go net/http | 82k (78k, 86k) | 12.07 µs | 17% | 782 µs | 793 µs | 2.08 ms |
1 CPU, 256 connections, 16 pipelined
| Server | req/s (both runs) | CPU per request | wrk CPU | Mean latency |
|---|---|---|---|---|
| hyper | 1.19M (1.19M, 1.20M) | 0.83 µs | 24% | 3.43 ms |
| mojo | 1.19M (1.11M, 1.26M) | 0.84 µs | 26% | 3.47 ms |
| fasthttp | 1.12M (1.12M, 1.13M) | 0.88 µs | 24% | 3.65 ms |
| nginx | 200k (197k, 203k) | 4.96 µs | 29% | 20.47 ms |
| go net/http | 103k (103k, 104k) | 9.64 µs | 12% | 39.69 ms |
1 CPU, 1024 keep-alive connections
| Server | req/s (both runs) | CPU per request | wrk CPU | Mean latency | p50 | p99 |
|---|---|---|---|---|---|---|
| hyper | 146k (145k, 147k) | 6.80 µs | 29% | 7.01 ms | 6.97 ms | 9.95 ms |
| mojo | 144k (141k, 146k) | 6.91 µs | 32% | 7.12 ms | 7.05 ms | 11.09 ms |
| nginx | 141k (140k, 143k) | 7.03 µs | 28% | 7.25 ms | 7.24 ms | 9.43 ms |
| fasthttp | 123k (122k, 124k) | 8.07 µs | 28% | 8.31 ms | 8.23 ms | 10.89 ms |
| go net/http | 67k (63k, 71k) | 14.96 µs | 16% | 15.45 ms | 15.03 ms | 23.71 ms |
4 CPUs, 256 keep-alive connections
| Server | req/s (both runs) | CPU per request | wrk CPU | Mean latency | p50 | p99 |
|---|---|---|---|---|---|---|
| mojo | 350k (349k, 351k) | 10.83 µs | 92% | 731 µs | 612 µs | 3.57 ms |
| hyper | 340k (338k, 343k) | 10.92 µs | 91% | 752 µs | 628 µs | 4.25 ms |
| nginx | 322k (320k, 323k) | 11.86 µs | 88% | 796 µs | 703 µs | 4.33 ms |
| fasthttp | 317k (317k, 317k) | 12.26 µs | 87% | 808 µs | 722 µs | 3.60 ms |
| go net/http | 154k (153k, 154k) | 25.11 µs | 45% | 1.66 ms | 1.60 ms | 6.54 ms |
4 CPUs, 256 connections, 16 pipelined
| Server | req/s (both runs) | CPU per request | wrk CPU | Mean latency |
|---|---|---|---|---|
| mojo | 2.15M (2.15M, 2.15M) | 1.83 µs | 67% | 1.90 ms |
| hyper | 2.07M (2.06M, 2.09M) | 1.90 µs | 66% | 1.98 ms |
| fasthttp | 1.95M (1.94M, 1.96M) | 2.02 µs | 60% | 2.10 ms |
| nginx | 403k (401k, 405k) | 9.87 µs | 63% | 10.16 ms |
| go net/http | 180k (180k, 181k) | 21.34 µs | 29% | 22.71 ms |
How the results were taken
Everything is driven by one script, bench/server/run.py. Nothing is done by hand between runs.
The servers
- mojo:
server.Serverwith a handler that callsres.text(200, "Hello, World!"), built withmojo build(-O3 and the host CPU by default). On one CPU it is one process. On four, a supervisor runs four worker processes, each pinned to one CPU with its ownSO_REUSEPORTlistener. - hyper 1.12 on tokio: one
current_threadruntime per CPU, each with its ownSO_REUSEPORTlistener, andpipeline_flushon. Built with fat LTO, one codegen unit,panic = "abort"andtarget-cpu=native. - fasthttp 1.77: one process per CPU (its prefork mode, as TechEmpower runs it), with Go 1.27, PGO and
GOAMD64at the host's level. - Go's net/http: a
ServeMuxroute, one process withGOMAXPROCSset to the CPU count, the same build flags. - nginx 1.31:
return 200 "Hello, World!", one worker per CPU pinned with an explicit mask,reuseport, access log off, keep-alive unbounded.
Every server sends Content-Type, Content-Length, Date and Server with the 13-byte body: 127 to 161 bytes per response. Before the measurements, run.py check sent each server one request and then 16 pipelined, on one and on four CPUs, and checked every answer. Some settings were tried and left out, each alternated against the chosen one: Go's GC settings, PGO on and off, mimalloc for hyper, pinning hyper's threads, and mojo without bounds checks. The benchmark's README says what each did.
The load
| Scenario | Server CPUs | Connections | Requests per write | wrk threads |
|---|---|---|---|---|
| 1 CPU, keep-alive | 1 | 64 | 1 | 4 |
| 1 CPU, pipelined | 1 | 256 | 16 | 4 |
| 1 CPU, 1024 connections | 1 | 1024 | 1 | 4 |
| 4 CPUs, keep-alive | 4 | 256 | 1 | 4 |
| 4 CPUs, pipelined | 4 | 256 | 16 | 4 |
taskset -c <wrk CPUs> wrk -t4 -c<connections> -d60s --timeout 10s --latency http://127.0.0.1:<port>/plaintext
# pipelined: add -s bench/server/pipeline.lua ... -- 16 (16 requests in each write)
Every connection stays open for the whole run. wrk is closed-loop: a connection sends its next request, or batch, only when the last one has been answered.
No shared cores
The server and wrk never share a physical core, so neither takes the other's cycles, caches or hyperthread sibling. run.py reads the CPU topology from /sys and pins both with taskset:
- wrk: cores 0 and 1 (four hardware threads).
- A one-CPU server: one hardware thread of core 2, with its sibling left idle.
- A four-CPU server: cores 2 and 3, all four hardware threads, at the lower all-core clock. That is why a request costs about twice the CPU there.
One run, step by step
- Cool-down: the machine idles for 15 seconds, and the busy time of every CPU is measured meanwhile. If other work used more than one CPU's worth, the run waits until it drops. None of the runs here had to wait.
- Start: the server starts afresh, in a process group of its own, on a free port, and the script waits until it accepts connections.
- Warm-up: the same load for 5 seconds (caches, lazy setup, the CPU clock), and its numbers are thrown away.
- Measurement: the load for 60 seconds. Around it, the script reads the server's CPU time and the whole machine's busy time. Every second it also samples the server CPUs' clock and the package temperature.
- Stop: SIGTERM to the server's process group, SIGKILL after 10 seconds if needed.
One server runs at a time, in the order mojo, Go, fasthttp, hyper, nginx. The whole set then runs again in reverse order, so that a slow drift over the hour, in temperature or background work, does not always favour the same server. The tables show the mean of the two runs, and both values.
What each number is
- Requests per second, p50 and p99: wrk's own output. There were no socket errors and no non-200 answers in any run.
- Server CPU per request: the user plus system time (
utime+stimefrom/proc/<pid>/stat) of every process in the server's process group, over the 60 seconds, divided by the requests answered. Over loopback this includes the kernel's TCP work for the server's sockets in both directions: it is the server's whole cost, not only its own code. - wrk CPU: wrk's own user plus system time, as a share of its four threads. Near 100% the client, not the server, is the limit.
- Others: the CPU the rest of the machine used during the run, which is all busy time minus the server's and wrk's. It was 0.2 to 0.9 CPUs here: a desktop session.
- Clock and temperature: 3.2–3.8 GHz on one CPU and 2.6–3.0 GHz on four, at 71–80 °C.
Every run's numbers, wrk's raw output, the CPUs used and the versions of everything go into one JSON file. The full method is in bench/server/README.md.
Caveats
- One laptop, with the client on the same machine, over loopback. There are no network, NIC or interrupt costs, and the kernel's loopback TCP is a large share of every request.
- wrk is closed-loop, so its latencies leave out the queueing that many independent users would see. Its corrected percentiles are wrong for pipelined runs, which is why only Little's-law means are given there.
- A desktop session shared the machine, and the CPU ran on the
powersavegovernor with turbo, so the clock moved with temperature and load. Both are recorded per run. The first one-CPU keep-alive run was the noisiest for every server. - A plaintext "Hello, World!" measures the HTTP machinery, not an application.
Where the time went
The day before the runs above, mojo took twice hyper's user time per request. Its kernel time was the same as hyper's to the hundredth of a microsecond, since both make the same system calls. To find where the rest went, both servers ran under valgrind's callgrind, which counts every user-space instruction by function and source line. The setup: one CPU, 16 connections with 16 requests pipelined in each, 3 seconds of warm-up, then 10 seconds counted. Dividing by the requests answered gives instructions per request.
- The cause was mostly HTTP header handling. Every request's head was walked several times (to parse it, check it, decide the framing, encode the response), and checked one byte at a time. Each layer also re-checked what the layer above had already checked.
- An error type passed in registers made every call that might fail cost about 85 extra instructions, even when it succeeded. Passed in memory, a success costs one flag test.
- The server's own fields: it now hands the HTTP codec its
DateandServerlines already encoded, once a second, and the body's length as a number. Before, it pushed them as header fields that were then checked, encoded and parsed back. - Smaller changes: the default
Content-Typeis set without re-checking a constant; field bytes are copied in 16- and 32-byte blocks; a request's path is scanned byte by byte instead of through a general string search. - One trap: creating an empty
Errorahead of time, to hold one that might be caught later, takes a stack trace in Mojo, about 60 instructions on every request. The errors are now handled where they are caught.
Instructions are not time, so the wrk runs above are the measure of speed. The profile only says where to look. Every hot spot, with file and line, is written up in why_mojo_is_slower.note.
How it handles concurrency without threads
Each process runs one loop over nonblocking sockets registered with Linux's epoll. Each turn, one epoll_wait returns whichever sockets are ready, out of any number. For each one, the loop writes what it can, reads what has arrived, feeds the HTTP parser, and, when a request is complete, calls the handler and queues the response. When a socket would block, it moves on to the next. A connection's progress lives in a small struct (waiting, reading a body, streaming, closing), not on a thread's stack. Timeouts are deadlines checked a few times a second.
- Fairness: a busy connection gets a slice of each turn (64 KiB or 128 requests), then waits for the next turn.
- Backpressure: a connection whose output is still waiting reads nothing more, so a client that doesn't read can't make the server buffer without limit.
- Streams (server-sent events, long polls, token streams) pause when they have nothing to send, until something wakes them. They need no thread each.
- Several cores: one process per CPU, each with its own loop and its own listener on the same port (
SO_REUSEPORT), so the kernel spreads new connections across them. Processes share nothing, so nothing needs a lock. A supervisor restarts workers that die and stops them gracefully on SIGTERM. It is the model nginx uses. In the benchmark, hyper ran the same way (one single-threaded runtime per CPU), as did fasthttp.
The cost of this model: a handler must not block, because a blocking call stalls every connection in its process. In an early test, one Mojo thread held 11,000 TCP connections this way (10,000 idle, 1,000 busy) without an error.
Things Mojo taught along the way
- A value is destroyed right after its last use. In
fcntl(owner.raw_fd(), …)the owner, and with it the file descriptor, is gone before the syscall runs. Every syscall wrapper therefore takes the owner itself. - An
Optionalof a large struct is slow when its destructor isn't inlined: about 900 instructions for a 256-byte struct. Returning one per request cost about 2 µs, so hot loops now fill a value the caller owns. - An error type passed in registers that holds a pointer is rebuilt byte by byte on every call that might fail, about 85 instructions even on success.
- Runtime indexes into a wide
SIMDvalue are slow. SHA-256 ran at 29 MB/s that way, and at about 260 MB/s with the loop unrolled at compile time. - A field reassigned in a loop, after another field of the same local struct was last used, corrupted the heap on Mojo 1.2.0: a compiler bug, with a workaround.
The full list, with a reproduction for each, is in the repository's CLAUDE.md.
Reproduce it
You need Linux on x86_64, pixi, and at least 8 hardware threads (or CPUs chosen by hand). pixi installs pinned toolchains: Mojo, Go, Rust, nginx and wrk.
git clone https://github.com/advenn/mojo_net && cd mojo_net
pixi run -e bench bench-server build # the five servers
pixi run -e bench bench-server check # each answers correctly, on 1 and 4 CPUs
pixi run -e bench bench-server run --reps 2 # 5 scenarios x 5 servers x 2, about 70 minutes
pixi run -e bench bench-server summary bench/server/out/<time>.json
pixi run -e bench bench-profile # mojo and hyper under callgrind
pixi run test # the library's own tests