Server performance: the raw HTTP hot path
Throughput and latency of the Mojo server serving Mojo handlers, with the environment each number was taken in.
Measured 2026-08-18, on the raw lightbug_http event loop (apps/hello,
single process, epoll backend). Companion to
WSGI_PERFORMANCE.md, which measures the Django/WSGI
stack; this document is about the server itself, with the handler cost near
zero.
Setup
- 4-core Linux container, 16 GB RAM, server and load generator on one box. Absolute numbers drift hard between sessions and comparing across them will mislead you. Measured directly: the same Go binary served 36k req/s in one session and 60k in another, a 1.7x swing from nothing but container load. Every comparison in this document was therefore taken in the same session as its counterparts, and any before/after claim comes from alternating A/B runs rather than a remembered number.
apps/hello/server.mojocompiled withmojo build(Mojo 1.0, theuv.lockpin). Single process,listen_and_serve_nonblocking, defaultServerConfig.- wrk 4.1.0: 2 threads, 16 connections, 10 s runs after warm-up, keep-alive,
against
/(13-byte plain-text body). Three runs per configuration; the tables show a representative middle run. - Competitors, same box, same wrk settings: Go 1.x
net/http(GOMAXPROCS(1), hello-world handler), Node 22http(single process), uvicorn (single worker, plain ASGI app, no uvloop). All four servers are CPU-saturated on their serving core at these rates (verified via/proc/<pid>/stat— each burns ~100% of one core).
Where the time went: a syscall budget
strace -c over a 5 s window of the pre-change server, keep-alive load:
| syscall | calls/request | notes |
|---|---|---|
recvfrom |
1.0 | necessary |
sendto |
1.0 | necessary — whole response in one send |
epoll_ctl |
2.0 | half of them failing with EEXIST |
timerfd_settime |
1.0 | idle-timeout re-arm, every request |
The epoll_ctl pair was the keep-alive re-arm: add_read tries EPOLL_CTL_ADD
(fails EEXIST — the fd is still registered), then falls back to MOD. The
timerfd_settime was the idle timer being re-armed after every response.
Five-plus syscalls per request where two suffice.
Beyond syscalls, gdb stack sampling under load put ~65% of the saturated
core inside __libc_send itself — on loopback, send() runs the peer's
softirq delivery and wakeup inline, so that share is partly the load
generator's cost billed to the server, and every server measured here pays
it. The user-space remainder concentrated in String.to_lowercase (header
Dict keys), HTTPResponse.encode, and allocation (tc_new).
Changes landed in this pass
Each was measured in isolation against the previous step, same session:
- Skip redundant event re-registration (
event_loop.mojo,slot_read_armed). Read filters are persistent on both backends; the only operation that disarms one isadd_write_oneshot(on epoll it replaces the fd's mask). Tracking that transition per slot eliminates both per-requestepoll_ctlcalls in steady state. - Idle timeout by deadline sweep (
slot_idle_deadline). Instead oftimerfd_settimeper request, each response stamps a deadline and the loop sweeps active slots at most once a second (the loop already wakes at least that often —wait(1000)). Timeouts are whole seconds; nothing observable changes. Removes the last per-requestepoll/timer syscall. Changes 1+2 together: 15.2k → 16.8k req/s (+10%). TCP_NODELAYon accepted sockets (set_tcp_nodelay, accept path). Responses are singlesend()s — there is nothing for Nagle to coalesce, it only delays a response behind the previous one's ACK. Every mainstream server disables it. 16.8k → ~19.1k req/s (+5% throughput, p50 0.93 ms → 0.81 ms).- Date header: format once per second, not once per response
(
http_date_from_unix+ loop-level cache). Formatting cost ~10 small String allocations plusgmtimeper response — measured ~9% of hello throughput when it ran per-response in theHTTPResponseconstructor. The constructor no longer stamps Date; the event loop injects a cached string (falling back toencode()'s own stamp for responses sent outside the loop). - ASCII fast path for header encoding (
write_header_latin1). The ISO-8859-1 transcode allocated a scratch buffer per header per response; pure-ASCII values (all of them, in practice) are now written directly. - Request construction on the cheap (
HTTPRequest.from_parsed): the parsed headerDictis now moved (swap) instead of deep-copied, and an origin-form path with no%-escape and no query string skipsURI.parseentirely — every derived URI field is the path itself. Neutral on wrk's minimal requests, but removes a full Dict copy + URI parse per request for real traffic.
After all six: syscalls per keep-alive request are exactly recvfrom +
sendto (plus a shared epoll_wait amortized over ~16 requests), and the
strace error column is zero.
Results
Same box, same wrk settings (t2/c16 keep-alive unless noted):
| server | req/s | p50 | p99 |
|---|---|---|---|
| mojo-http before this pass | 15,200 | 1.03 ms | 1.7 ms |
| mojo-http after | 18,900 | 0.83 ms | 1.45 ms |
Go net/http (GOMAXPROCS=1) |
36,000 | 0.44 ms | 1.2 ms |
Node 22 http |
18,200–26,500 | 0.54 ms | 1.6 ms |
| uvicorn (no uvloop) | 5,000 | 3.1 ms | 5.1 ms |
+24% throughput, −20% p50. The patched server holds its rate under
concurrency — 18.3k at c64, 17.3k at c256 (p99 21.8 ms, purely queueing) —
and does 11.4k in Connection: close mode, where accept-path costs
(2 × fcntl, header-timer timerfd_create/epoll_ctl/close) dominate.
Node's range reflects genuine run-to-run drift in this shared container.
Go's remaining ~1.9× is real and is not syscalls — both servers now issue the same two per request. It is the per-request object machinery, detailed next.
Span-based headers
The item ranked first below landed next, and it was the largest single win in this file's history: +72% throughput on top of everything above.
Headers stored a Dict[String, String]. Filling it cost two String
allocations per header, .lower() allocated a third to normalize each name,
and every lookup allocated a fourth — key.lower() builds a probe copy
before it can hash. At 5-15 headers per request that is dozens of
allocations to answer questions like "is this connection close?".
It now stores one flat byte blob holding every name and value back to back, indexed by parallel (offset, length) arrays — the SoA pattern used elsewhere in this repo. Names are lowercased on the way in, so a lookup is a linear scan comparing lengths first and folding only the probe's bytes: no allocation at all. Over 5-15 entries that beats hashing outright, and insertion order is preserved, which the Dict never guaranteed.
Three things fell out of the new representation for free:
content_length()parses digits straight out of the blob instead of materializing a String first — it runs for every request with a body.value_equals_ignore_case()answers theConnection: closequestion without building the two Strings the oldget(k).value().lower() == vneeded, on every single request.- The parser dispatches field names with
name_is()against known- lowercase constants, and trims OWS by moving span endpoints, so a header only becomes a String if it is a cookie.
Measured by alternating A/B runs in one session (wrk t2/c16 keep-alive):
| variant | req/s | p50 | p99 |
|---|---|---|---|
Dict[String, String] |
29,000 | 535 us | 0.90 ms |
| flat blob + offsets | 50,000 | 320 us | 485 us |
Five alternating rounds; the spread within each variant was under 5% and
the two never overlapped. Go net/http measured 60k in that same session,
so this moved mojo-http from 0.48x to 0.83x of Go on the same box.
Note what these absolute numbers are not comparable to: the 18.9k in the table above was a different session on the same container. Against that session's Go baseline of 36k, 18.9k was 0.53x. The honest way to read the two tables together is by ratio, not by subtraction.
How much this depends on header count. The A/B above used wrk's default
request, which sends a single Host header — the least favourable shape
for a change whose cost scaled with header count. Measured separately on the
Django path, where the same before/after pair was run at one header and at
twelve: the benefit was 2.4x larger with twelve
(see WSGI_PERFORMANCE.md). Real traffic sits at the
twelve end, so the number in the table is a floor, not a ceiling.
The profile then flipped. Before, stack samples showed
String.to_lowercase, Headers.__init__, and the allocator. After, 31 of
35 samples sit in __libc_send — the write syscall itself — with exactly
one in the allocator and one each in set_bytes and Headers.__init__.
The server is now syscall-bound on loopback rather than allocation-bound.
That result retires the rest of the header work. The parser still builds a
String per name and per value in parse_headers (create_string_from_reader),
and the original plan was to make HTTPHeader hold offsets too. The profile
says that is no longer worth its complexity: the leaf cost it would remove
does not appear in the samples. Revisit only if a future profile disagrees.
What this deliberately did not do
The literal design sketched below — spans pointing into the provision's
receive buffer, with zero copy at all — would require Headers to carry an
origin parameter, which propagates to ParsedRequestHeaders, HTTPRequest,
and therefore the HTTPService trait. That breaks every handler in the
repo at once: all six apps, the five demo services in service.mojo, and
the README example. The blob copies the header bytes once per request and
gets the allocation win without touching a single caller — Headers's
public API (get, [], in, pop, keys, content_length) is
unchanged, which is why this landed as a pure internal substitution.
(HTTPService's methods later gained default bodies, which does not change
this. A default removes the cost of adding a hook; it does nothing for
changing a signature, and func — the one method with no default — is the
one an origin parameter would rewrite.)
Where the remaining gap lives
The canonical fast HTTP/1.1 servers (fasthttp, may-minihttp/ntex, and
TechEmpower's top plaintext entries generally) share one design decision:
nothing request-scoped is allocated. fasthttp documents it directly —
headers stay []byte slices into the read buffer, request/response objects
are pooled per connection, []byte→string conversions are avoided in hot
paths. The header half of that checklist is now done (above). What each
request still allocates:
- two Strings per header inside
parse_headers(create_string_from_reader) beforeHeaderscopies the bytes into its blob — the one remaining piece of the header story, and the profile says it is no longer worth chasing; - an
HTTPRequest(cookie jar, URI struct with 12 String fields) and anHTTPResponse(its ownHeaders, 3–4 inserts); a fresh— done, and it was worth less than this list implied — but the field it uses was already costing memory. It was never blocked: a buffer moves out of a list-element field byByteWriterper responseswap, the same idiomfrom_parsedandHeadersalready used, verified on the 1.0 toolchain. Each slot now rotates one buffer betweenencode_intoand_after_send. Measured in isolation at 76 ns per response (1.04x on the encode step), and not separable from run-to-run noise end to end — see below;- a copy of the body bytes out of
recv_buffer.
Ranked next steps, by expected value:
-
Response encode into a reusable per-slot buffer— done; this ranking was wrong. The change is in, and the honest result is that it buys 76 ns per response in isolation and nothing measurable end to end. An alternating A/B of the two binaries (ab -k -c16 -n20000, five rounds each) put both between 75k and 89k req/s with the distributions fully overlapping. That is what the post-header profile already predicted — one allocator sample out of 35 — and the general lesson is worth keeping: after the header work, no remaining allocation on this path is large enough to see through loopback noise. Rank the next two accordingly.It landed anyway, and on a different argument than throughput.
ProvisionPooleagerly constructs every provision at startup —for _ in range(capacity)overmax_connections, 1024 by default — and each one allocated anencoding_bufferofsocket_buffer_size. That is ~4 MB of heap per process that nothing read or wrote. The choice was never "76 ns or nothing"; it was "use the buffer, or delete the field". Using it also retires the false claim that Mojo cannot move out of a list-element field, which is what put this item at the top of this list in the first place. -
Close-mode accept path:
accept4(SOCK_NONBLOCK)(one syscall instead of accept + 2 ×fcntl), and pool header-timeout timerfds instead of create/close per connection. Only matters for non-keep-alive clients; the close-mode p99 suggests accept-queue latency is the tail. -
Body handling: hand the handler a span into
recv_bufferinstead of a copiedBytes(needs a lifetime story for the handler contract).
Temper all three against the post-header profile: with 31 of 35 samples in
__libc_send, user-space work is no longer what limits this server on
loopback. The next honest win is likely more sockets doing the sending —
M0_WORKERS prefork already exists (see WSGI_PERFORMANCE.md) and
everything here compounds per worker — rather than more shaving in the
request path. Measure before building.
The instrument, and what it overturned (2026-08-28)
Everything above was measured with wrk and gdb stack samples, and the
post-header profile's verdict — 31 of 35 samples in __libc_send, "no
remaining allocation on this path is large enough to see through loopback
noise" — retired the rest of the header work. That verdict was drawn at
~50k rps, when a request cost ~20 µs. The server now does 116k rps/core, a
request costs ~8.6 µs, and a cost that was 5% of the old request is 12% of
the new one. Loopback sampling cannot see a change of a few hundred
nanoseconds; a per-part instrument can, and scripts/bench_http_parts.mojo
is that instrument — the same shape as bench_bridge_parts.mojo, one layer
down. Twelve-header browser GET, 20k iterations, Apple M4:
| part | before | after |
|---|---|---|
find_header_end |
0.047 µs | 0.046 µs |
parse_request_headers |
2.520 µs | 2.052 µs |
from_parsed (derived) |
0.261 µs | 0.281 µs |
Headers lookups (in, get, value_equals_ic, content_length) |
40–70 ns | 40–75 ns |
Router.match hit / miss |
0.100 / 0.027 µs | 0.099 / 0.029 µs |
OK() construct |
0.672 µs | 0.651 µs |
encode_into (derived) |
0.275 µs | 0.347 µs |
| whole user-space request | 3.743 µs | 3.309 µs |
Two things the table says that the profile did not. The parse is two
thirds of the user-space request, not a leaf; and the two allocation
findings this pass set out to measure are noise — the
Array[HTTPHeader, 100] fill was 66 ns and parse_http_version's List
39 ns. What was not noise was the item ranked first in "Where the remaining
gap lives" and then retired: the two Strings per header that
parse_headers built for parse_request_headers to read back as bytes and
copy into the blob. HTTPHeader now holds four offsets into the buffer the
parser was given, the token scanners expose their spans (scan_token,
scan_to_eol; the String wrappers stay for the request line and the
response status), and the wrappers slice the buffer they already hold. The
Array fill became 3.2 KB of integers as a side effect, and
parse_http_version compares seven bytes instead of building a list.
−0.47 µs on the parse, −12% on the user-space request, with all 556 m0-http tests and the eight parse-sensitive smokes (pipelining, the 8 KB request, half-close, the client's response parser, cookies, the WebSocket handshake, the header timeout) unchanged. Less than the 1.2 µs a scratch attribution predicted for 24 allocations: short names and values fit Mojo's inline string storage and never touched the heap, which is the kind of thing only the measurement settles.
What was left in the 2.05 µs was recorded as the next lever: below 64
bytes remaining the scanners fell back to a try_peek per byte,
set_bytes appended and lowercased twelve names into the blob, and the
wrapper's three RFC checks were three linear in scans.
The lever, taken (2026-08-29)
The same instrument, plus a scratch spike splitting the parse row in two,
said where the 2 µs actually went — and the split was not what the entry
above assumed. The low-level scanner (http_parse_request_headers) was
1.07 µs, of which 0.3 µs was the Array[HTTPHeader, 100] fill — the
66 ns the table above measured for it was the fill in isolation, where the
compiler could elide most of it; in the parse, passed to a function, it is
3.2 KB of stores per request. The wrapper — the blob build and the RFC
checks — was 1.16 µs, more than half. And inside the scanner, the SIMD
helpers were spending their time not in SIMD: having learned with one
reduce_min that a chunk held a match, they walked up to 64 lanes with a
scalar loop to find which — 9.4 ns per chunk for a CR at lane 46,
against 0.8 ns for a select of iota and a second reduce_min that
names the lane with no branch.
Four changes, in the order they paid: the lane walk replaced by the
select/iota idiom in every scanner, each now running 64 lanes wide,
then 16, then scalar, so the last headers of a request stop falling to a
try_peek per byte; the offsets array uninitialized rather than filled
(the parser writes all four offsets of an entry before counting it);
Headers.reserve sizing the blob and index once from the bytes consumed
and the field count, with a value copied in by one extend (twelve inserts
had been growing the blob through nine reallocations and the index through
seven); and the three RFC scans folded into the dispatch loop the wrapper
already ran — the Host check had been building a String to measure its
length. Same twelve-header browser GET, 20k iterations, medians of three:
| part | before | after |
|---|---|---|
find_header_end |
0.045 µs | 0.016 µs |
parse_request_headers |
1.96 µs | 0.86 µs |
from_parsed (derived) |
~0.2 µs | ~0.2 µs |
Headers lookups |
32–67 ns | 31–69 ns |
Router.match hit / miss |
0.099 / 0.027 µs | 0.093 / 0.026 µs |
OK() construct |
0.66 µs | 0.52 µs |
encode_into (derived) |
0.37 µs | 0.36 µs |
| whole user-space request | 3.33 µs | 1.97 µs |
−1.1 µs on the parse (−56%), −40% on the user-space request, with the
OK() row's −0.14 µs a side effect: a response's headers go through the
same set_bytes. The instrument gained a warm-up pass on the way — with
the parse under a microsecond, its row (the first heavy loop) read the
allocator's cold start, 0.85 in one run and 1.20 in the next with the
parse-plus-build row below it.
One answer changed, and the old one was wrong: the wide scan looked for the first CR and only then for any other control byte, so a field value ended by a bare LF ran on to the next line's CR whenever one lay in the same 64-byte chunk, and that line vanished into the value. The mask is now the scalar tail's own predicate, so the widths agree; the test that pins it fails on the old scanner, and length sweeps from 1 to 140 bytes for values, names and targets cross every hand-off between widths in both directions (a 16-wide block returning its lane without its base fails the value sweep and nothing else — that is what the sweeps are for).
On the wire it is +23% to +24%. apps/hello built from the old and
the new parser, run side by side on distinct ports (SO_REUSEPORT would
otherwise let one take the other's connections), byte parity asserted
first, browser headers, keep-alive, three rounds alternating between the
two so drift lands on both, cores measured off each process
(bench/results/parse-lever-ab/hello-wrk-parse-ab-20260829T043918Z.json,
medians):
| old | new | ||
|---|---|---|---|
| c16 rps / p50 / cores | 122.1k / 113 µs / 0.98 | 150.2k / 90 µs / 0.97 | +23% |
| c64 rps / p50 / cores | 123.1k / 487 µs / 0.98 | 152.1k / 390 µs / 0.96 | +24% |
Re-measured later the same day on a machine confirmed idle (another
Claude Code session had run alongside some of the day's later benchmarks,
and this row has no in-run comparator to show it clean):
hello-wrk-parse-ab-20260829T145148Z.json, c16 118.9k → 142.3k (+20%),
c64 122.0k → 152.0k (+25%), p50 114 → 91 µs and 491 → 391 µs — the same
gain within run-to-run drift, so the first table stands.
That is ~155–158k rps per core on the hello row where the page above says 116k, and the arithmetic checks: −1.36 µs of user-space work on a request that was ~8.6 µs all-in is −16% of the request, and 1/0.84 is 1.19 before the cache effects of touching less memory per request. The p50s move by 23 µs at c16 and 97 µs at c64 — the same per-request cost removed, multiplied by the connections queued behind it in a closed-loop client.
Through the ASGI executor the same change is worth +8–10%, because the
parse is a smaller share of a request that also runs Python: on the
benchmark page's row (stdlib asyncio, c16, uvicorn asyncio re-measured
beside each arm, bench/results/parse-lever-ab/) the pump went 55.7k →
60.1k rps and M0_INVERTED=1 54.5k → 59.7k — both now above the
comparator's ~58.5k, which neither was before — and on the uvloop
executor 63.2k → 69.1k and 60.2k → 66.4k. ROADMAP.md's inversion entry
carries the reading.
What remains is ~0.86 µs of parse against the 5.3 µs of a request that is the two syscalls, which no parser touches.
The outbox sweep (2026-08-29)
The other per-pass cost the inversion entry in ROADMAP.md had named.
Every pass swept all 1,024 slots for a streaming connection whose outbox
needed draining, and the miss path — two flag loads per slot, none set —
measured 1.2–1.3 µs per pass in isolation (a scratch spike over two
List[Bool]s of 1,024). A pass carries one or two requests at c16, so
that is a visible share of a request that costs 8.6 µs on the hello row
and ~15 µs through the inverted executor.
The ceiling first, with the sweep skipped outright on builds whose
benchmark routes never stream (bench/results/outbox-sweep/, uvicorn
re-measured beside every executor arm and within the clean band on all
of them):
| with sweep | without | ||
|---|---|---|---|
| hello c16 | 146.5k @0.99 | 151.6k @0.98 | +3.5% |
| hello c64 | 154.7k @0.99 | 157.9k @0.97 | +2.1% |
executor, M0_INVERTED=1, c16 |
59.3k @0.88 | 61.7k @0.89 | +4.1% |
| executor, pump, c16 | 60.0k @0.97 | 58.2k @1.03 | −2.9%, +6% CPU |
| executor, pump, c256 | 79.1k @1.02 | 79.0k @1.01 | ±0 |
The pump row is the finding. Its loop thread does not run the
application; it parks requests and flushes them to the executor thread
as one datagram per pass, and a loop that returns to wait a
microsecond sooner finds fewer events waiting, batches fewer submits,
and wakes the executor more often per request. The sweep's microsecond
was accidental pacing, and at c256 — where batches are large regardless
— it makes no difference either way.
So the gate is scoped: OffloadLoopState.streaming_hint, an upper bound
on the flagged slots that the two flag-setting sites raise and the sweep
itself recounts, skips the sweep while it is zero — except when the pool
says sweeps_every_pass, which only the pump's wiring sets. Realized:
hello 152.3k → 157.4k at c16 (+3.3%, +5.5% per core), +1.3% at c64;
inverted 59.3k → 62.0k (+4.6%); pump 60.0k → 59.7k @0.98, parity
within noise.
The pump row's question — is an explicit pause better than the
accidental one? — was answered the same day with fourteen more arms
(bench/results/outbox-sweep/pacing/; the table is in ROADMAP.md,
"Pacing the pump's loop thread"). A spin of 1.2–2 µs on every pass
reproduces the sweep to within noise (60.0–60.1k against 59.95k); a
spin only before a partial flush does nothing (58.6–59.0k, the same as
no pause), because the batch is already whatever the previous wait
returned — the pause that matters is the one after writing responses,
which lets the clients' next requests arrive before the loop parks; and
re-polling after the pause to fold new events into the same batch is
worse (55.7k at 1.08 cores). The pump keeps its pacing through the
sweep it already runs; there is no knob to add. The per-byte token validation
(is_token_char over a name's bytes) was measured against a bit table and
is a wash — 1 ns per byte either way — and is not a lever. Nor is a
state-machine rewrite in the picohttpparser shape: the row it would
attack is now a fifth of the user-space request and a tenth of the whole.
Non-goals, considered and rejected
- Pipelined-request support — no longer a non-goal: implemented
(RFC 9112 §9.3), because dropping the tail was never just a benchmark
gap — it was a hang for any client that pipelines.
request_endmarks where an answered request ends, the keep-alive reset preserves the bytes past it, and_drain_pipelinedanswers them without a fresh read edge;poe smoke-pipeliningpins it. The benchmark note this entry used to carry still stands: TechEmpower plaintext runs 16-deep pipelining and flatters servers that batch parse/write — mojo-http now answers that shape, but attempts no batching: one response per parse, one send each. writevfor header/body split — the response is already assembled into one buffer and sent with one syscall; vectored IO would only help if encode stopped copying the body, i.e. after next-step 4.- io_uring — a different backend entirely; the epoll loop is not the bottleneck at current rates.
Reproducing
wrk is not a dependency of this repo (same policy as gunicorn in the WSGI doc). The shape of a run:
uv run poe build-all
uv run mojo build -I packages/m0-core/ -I packages/m0-http/ \
apps/hello/server.mojo -o /tmp/hello_server
/tmp/hello_server &
wrk -t2 -c16 -d10s --latency http://127.0.0.1:8080/ # keep-alive
wrk -t2 -c16 -d10s -H 'Connection: close' http://127.0.0.1:8080/
# Syscall budget (the strace overhead inflates µs/call ~10×; use the
# calls and errors columns, not the time):
strace -c -p "$(pgrep -x hello_server)" & sleep 5; kill -INT %%
scripts/bench_hello.sh automates the matrix (baseline + concurrency
sweep) and prints a summary table.