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.

1.19M
requests/s pipelined on one CPU, level with hyper (1.19M)
2.15M
requests/s pipelined on four CPUs, against hyper's 2.07M
4,227
instructions per request, down from 9,644 a day earlier
0
threads, async tasks or libc calls on the I/O path

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:

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).

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.

1 CPU, 64 keep-alive connections
hyper172k
mojo168k
fasthttp162k
nginx156k
go net/http82k
1 CPU, 256 connections, 16 pipelined
hyper1.19M
mojo1.19M
fasthttp1.12M
nginx200k
go net/http103k
1 CPU, 1024 keep-alive connections
hyper146k
mojo144k
nginx141k
fasthttp123k
go net/http67k
4 CPUs, 256 keep-alive connections
mojo350k
hyper340k
nginx322k
fasthttp317k
go net/http154k
4 CPUs, 256 connections, 16 pipelined
mojo2.15M
hyper2.07M
fasthttp1.95M
nginx403k
go net/http180k

What it shows:

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:

Scenariomojo, 27 Octmojo, 28 Octchangehyper, 27 / 28 Oct
1 CPU, 64 keep-alive connections156k168k+8%181k / 172k
1 CPU, 256 connections, 16 pipelined762k1.19M+56%1.17M / 1.19M
1 CPU, 1024 keep-alive connections132k144k+9%144k / 146k
4 CPUs, 256 keep-alive connections301k350k+16%350k / 340k
4 CPUs, 256 connections, 16 pipelined1.33M2.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

Serverreq/s (both runs)CPU per requestwrk CPUMean latencyp50p99
hyper172k (162k, 183k)5.77 µs41%373 µs360 µs612 µs
mojo168k (153k, 183k)5.94 µs40%384 µs368 µs749 µs
fasthttp162k (154k, 169k)6.16 µs37%397 µs398 µs992 µs
nginx156k (142k, 171k)6.40 µs37%413 µs394 µs764 µs
go net/http82k (78k, 86k)12.07 µs17%782 µs793 µs2.08 ms

1 CPU, 256 connections, 16 pipelined

Serverreq/s (both runs)CPU per requestwrk CPUMean latency
hyper1.19M (1.19M, 1.20M)0.83 µs24%3.43 ms
mojo1.19M (1.11M, 1.26M)0.84 µs26%3.47 ms
fasthttp1.12M (1.12M, 1.13M)0.88 µs24%3.65 ms
nginx200k (197k, 203k)4.96 µs29%20.47 ms
go net/http103k (103k, 104k)9.64 µs12%39.69 ms

1 CPU, 1024 keep-alive connections

Serverreq/s (both runs)CPU per requestwrk CPUMean latencyp50p99
hyper146k (145k, 147k)6.80 µs29%7.01 ms6.97 ms9.95 ms
mojo144k (141k, 146k)6.91 µs32%7.12 ms7.05 ms11.09 ms
nginx141k (140k, 143k)7.03 µs28%7.25 ms7.24 ms9.43 ms
fasthttp123k (122k, 124k)8.07 µs28%8.31 ms8.23 ms10.89 ms
go net/http67k (63k, 71k)14.96 µs16%15.45 ms15.03 ms23.71 ms

4 CPUs, 256 keep-alive connections

Serverreq/s (both runs)CPU per requestwrk CPUMean latencyp50p99
mojo350k (349k, 351k)10.83 µs92%731 µs612 µs3.57 ms
hyper340k (338k, 343k)10.92 µs91%752 µs628 µs4.25 ms
nginx322k (320k, 323k)11.86 µs88%796 µs703 µs4.33 ms
fasthttp317k (317k, 317k)12.26 µs87%808 µs722 µs3.60 ms
go net/http154k (153k, 154k)25.11 µs45%1.66 ms1.60 ms6.54 ms

4 CPUs, 256 connections, 16 pipelined

Serverreq/s (both runs)CPU per requestwrk CPUMean latency
mojo2.15M (2.15M, 2.15M)1.83 µs67%1.90 ms
hyper2.07M (2.06M, 2.09M)1.90 µs66%1.98 ms
fasthttp1.95M (1.94M, 1.96M)2.02 µs60%2.10 ms
nginx403k (401k, 405k)9.87 µs63%10.16 ms
go net/http180k (180k, 181k)21.34 µs29%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

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

ScenarioServer CPUsConnectionsRequests per writewrk threads
1 CPU, keep-alive16414
1 CPU, pipelined1256164
1 CPU, 1024 connections1102414
4 CPUs, keep-alive425614
4 CPUs, pipelined4256164
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:

One run, step by step

  1. 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.
  2. Start: the server starts afresh, in a process group of its own, on a free port, and the script waits until it accepts connections.
  3. Warm-up: the same load for 5 seconds (caches, lazy setup, the CPU clock), and its numbers are thrown away.
  4. 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.
  5. 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

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

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.

User-space instructions per request (callgrind, pipelined, one CPU)
mojo, 27 Oct (before the reviews)9,644
mojo, after the HTTP review6,417
mojo, after the server review4,227
hyper4,565

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.

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

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