for servers that stay within their bounds
Fast by design. Explicit about limits.
An experimental HTTP/1.1 framework and reference server for Zig 0.16.0, built on native event-driven networking.
| Platform | Native backend | Network progress |
|---|---|---|
| Linux | io_uring |
The kernel receives asynchronous operations through submission queues and reports their completion. |
| macOS | kqueue |
Readiness events drive nonblocking socket operations. |
| Windows | IOCP — I/O completion ports | Overlapped socket operations report completion to their owning thread. |
Each network owner is a thread that manages many connections through readiness or completion events. While one connection waits for network progress, its owner can service other ready connections. The framework reserves memory and threads before serving requests, with explicit connection, request, and response limits. The build target selects the native backend automatically.
The repository and executable use bounded-http.
The Zig package and module use bounded_http.
The project was previously named zig-http.
Read the published whitepaper in your browser. The site includes rendered Markdown guides and syntax highlighting.
Start with the architecture guide to understand clusters, ownership, startup, and request processing. The integration guide explains dependency setup, callbacks, resource limits, and response writing. The illustrated whitepaper presents the design and qualified performance results. Open the HTML file in a browser; the document includes every SVG and works offline. The independent embedding example demonstrates the complete application lifecycle.
Application callbacks can run on the network owner or on fixed startup workers. Inline execution is the default for trusted bounded, nonblocking handlers; blocking callbacks must explicitly select fixed startup workers. Worker callbacks can flush output without returning. The same reserved worker stack resumes after the owner completes transmission. For callbacks that return between events, application notifications let producers wake retained requests without keeping a worker occupied. Each connection has one bounded, generation-checked coalescing signal; timers and request deadlines remain explicit. The Windows receipt records native x64 evidence and its limits. This is the M4 implementation informed by the adjacent Zig LLM Wiki.
The first goal is a working ownership and pending/resume model that we can measure and change. This is not a production qualification or a TechEmpower ranking. It listens on IPv4 loopback only, speaks plain HTTP/1.1, and has no TLS, proxy protocol, upgrade/tunnel implementation or general file server.
Run these commands from the repository root with the exact zig compiler on
PATH; verify checks that its version matches .zig-version.
Python 3 is needed for the version check, integration suite and smoke client.
ReleaseSafe is the preferred experiment mode; assertions remain enabled.
zig version
zig build verify -Doptimize=ReleaseSafe
zig build -Doptimize=ReleaseSafe
./zig-out/bin/bounded-http --port 8080 --connections 128On Windows, use .\zig-out\bin\bounded-http.exe from PowerShell.
Windows defaults to one I/O shard; --shards 3 enables three independent IOCP owners.
One listener distributes socket ownership through startup-bounded queues.
See the architecture guide and native shard receipt.
The version must print 0.16.0. The server prints READY to stderr after
startup. Linux and macOS accept SIGINT or SIGTERM; Windows consoles accept Ctrl-C or Ctrl-Break.
--duration-ms 30000 requests
shutdown after a finite run. --port 0 asks the OS for an available loopback
port, reported in READY. The HTML file is loaded before serving starts.
| Route | Behavior |
|---|---|
/plaintext |
Hello, World!, exactly 13 bytes, text/plain. |
/ and /index.html |
The startup-loaded assets/index.html; --index FILE selects another file, limited to 65,535 bytes (the current file-reader bound is exclusive). |
/echo |
Echoes the complete bounded request body through borrowed spans; accepts Content-Length or chunked framing. |
/chunks |
Three flush/resume turns followed by finish, producing first second third with chunked response framing. |
/stall |
Worker-mode blocking fixture; inline returns 501. |
/buffered |
Writes its distinct raw request target into the output arena; 413 if it exceeds Writer.capacity(), and a flush/retry when the shared arena is momentarily full. |
/borrowed-body |
Borrows a fixed-length request body through finish; chunked input receives 501. |
| Other paths | 404. CONNECT receives 501; no tunnel is opened. |
HEAD follows the handler path but suppresses response body bytes. Keep-alive
and ordered pipelining are supported, with one active callback per connection and a bounded batch of frozen finished responses.
The parser validates all framing/header syntax and interprets only the fields
needed for framing and connection control. request.header(name) performs a
lazy, case-insensitive lookup; its first matching value is borrowed, not copied
or combined with repeated fields. Chunk trailers are validated and discarded.
Run the wire/ownership suite against the installed binary, and optionally check Debug as well:
python3 tests/integration.py --server zig-out/bin/bounded-http --json .zig-cache/integration.json
zig build verify -Doptimize=DebugFor Linux Debug, build.zig explicitly selects bundled LLVM/LLD after the native
ELF linker rejected .sframe relocations in the Linux test host's GCC 16 CRT.
ReleaseSafe uses Zig's default toolchain selection. ReleaseFast and ReleaseSmall
are deliberately rejected. The integration suite
launches its own finite server instances and tests wire framing, fragmentation,
16-request pipelining, partial sends, flush/resume, overload recovery, worker
stall separation, request deadlines, slow readers and shutdown ownership. Its
client deadlines and process watchdogs are test bounds, not latency promises.
See evidence inputs for source scope. Native Linux, macOS, and Windows
gates establish their own results; cross-compilation never replaces them.
To use the framework, import the bounded_http module exported by
build.zig, provide an api.Handler, and follow the
startup/run/stop lifecycle in src/main.zig. Its actual demo
callback is the maintained example. src/server.zig exposes
Config, Cluster, Server, api and Budget; builds link libc. Treat this API as
experimental and read the ownership contract before
retaining slices or adding asynchronous application work.
The default ./zig-out/bin/bounded-http uses inline execution, gather sends, up to 128 responses per batch
and, on Linux, one I/O shard per CPU the process may run on (--shards N sets it; macOS and Windows default to one).
Windows also accepts explicit multiple inline shards through bounded socket handoff.
--execution inline selects it explicitly.
It provisions zero application workers and runs the same handler/writer path
on the I/O owner. Callbacks and flush resumptions must be short and nonblocking;
they must not sleep, perform blocking I/O or wait for work. The server cannot
preempt a violating callback or enforce deadlines while that callback runs.
The demo /stall route therefore returns501 in inline mode. Explicit
--execution inline --workers 2 is rejected. Worker mode is selectable
with --execution workers --workers 2; this is an execution-policy experiment,
not yet a per-request offload API. Worker execution currently requires one shard.
With several inline shards, callbacks on different connections can run
concurrently; shared application state must be immutable or synchronized.
Run python3 tests/inline_integration.py for inline framing, partial sends,
empty flush/resume, bounds and shutdown gates. Execution mode and separate
inline/worker dispatch counters accompany READY/STATS. Both modes retain all
safety assertions, borrowed payload ownership and configured connection limits.
A callback receives event = .request after the complete request has arrived,
a writer, eight zeroed state words for its continuation, an application pointer
and a cooperative cancellation flag. Use begin once; then reserve/commit
to produce bytes directly in output storage, or borrow for eligible existing
bytes. write is the explicit convenience-copying path.
return writer.flush() means send everything currently committed. It
freezes that output snapshot and yields the callback. Partial sends continue on
the I/O owner. Once all earlier batched responses and this snapshot have been accepted by the local socket, the
handler resumes with event = .flushed and an empty writable buffer. Success
means the local socket accepted the bytes; it does not prove peer receipt.
return writer.finish() sends the remaining bytes and completes HTTP framing,
without another application callback. No later writes belong to that response.
return try context.wait(delay_ns) releases the callback until a monotonic timer expires.
The handler resumes with .timer; the original request deadline still applies.
Applications can request cancellation callbacks to release retained state after abandoned waits or flushes.
These callbacks run on the configured application executor after kernel borrows return.
borrow requires request-owned input or immutable server-lifetime storage.
begin copies its content_type argument during the call.
There is no dynamic lease-release callback
on finish/cancellation. Stack locals and externally recycled buffers must not
escape a callback this way. If reserve returns WouldBlock, flush the existing
committed bytes and continue from context.state on resume; a reservation larger
than the entire configured output buffer must be split. Commit only initialized
bytes and return immediately after flush/finish.
The default startup limits are explicit:
| Resource | Default | Configuration |
|---|---|---|
| Admitted connections/request slots | 128 | --connections; 1–4096. |
| Application workers | 0 | Inline default; --execution workers selects 2 unless --workers sets 1–64, at most the connection count. |
| Request body | 64 KiB | --max-body; at most 16 MiB, cumulative after chunk decoding. |
| Header bytes | 16 KiB | --max-header; 128 bytes–64 KiB, including request line and shared trailer budget. |
| Header/trailer count | 64 combined | Config.max_headers; 1–1024. |
| Request target | 8192 bytes | Fixed MVP parser setting, also subject to the header budget. |
| Receive wire buffer per slot | max_header + 2 * max_body + 4096 |
Checked startup derivation; chunk framing consumes this independent bound. |
| Output arena per connection | 64 KiB | --output-bytes; 1 KiB–1 MiB; holds heads, generated bodies, framing and copied small borrows of one batch. |
| Response cells per connection | 128 inline, 1 workers | --response-batch-limit; 1–511, effective limit 1 in worker mode. |
| Borrow copy threshold | 256 bytes | --borrow-copy-threshold; borrowed spans up to this size are copied into the arena; 0 keeps every borrow a separate vector. |
| Inline callbacks per event-loop turn | connections × batch limit, at most 8192 | --callbacks-per-turn; each connection gets at most its batch limit per turn from a FIFO ready ring. |
| Deadline sweep | 100 ms | --deadline-sweep-ms; 1–1000; touched slots are checked sooner. |
| Submit batch | 0 (at poll) | --submit-batch; submit queued sends after this many drains within a turn; measured no gain on omarx1, kept as an experiment. |
| Pre-armed receive | off | `--prearm-receive 0 |
| Per-callback timing | off | --callback-timing 1 records exact queue/handler maxima at two clock reads per callback. |
| I/O shards | one per allowed CPU (Linux, at most 16), 1 (macOS and Windows) | Linux and Windows accept --shards 1–64 with inline execution. Each shard reserves full slot storage. Shared admission preserves the connection ceiling. Linux --shard-affinity 1 pins each shard to its allowed CPU. |
| Logical response body | 16 MiB | --max-response; counted across flushes. |
| Request cycle deadline | 5000 ms | --timeout-ms; includes receive, worker queue/execution and response sending. |
| Shutdown drain deadline | 5000 ms | Config.shutdown_ms; positive. |
| Worker stack request | 1 MiB each | Config.worker_stack_bytes; at least 64 KiB. |
| Startup memory budget | 512 MiB | --memory-budget; see the accounting boundary below. |
| Requested socket send buffer | 64 KiB | --socket-send-buffer; positive, up to 16 MiB. The OS may adjust this request. |
| Bytes per send operation | 64 KiB | --send-chunk; positive, useful for forcing partial progress in tests. |
Startup rejects inconsistent or over-budget configurations. The configured
connection count bounds admitted slots, not the kernel TCP backlog. The server
may transiently accept one extra descriptor per shard/listener and immediately
close it when all admitted slots are
occupied; it creates no extra request state and does not promise an HTTP 503.
Each connection has one receive and one send operation cell plus a cancel
cell for each, 4 * connections + 2 operation records per shard
(shards * (4 * connections + 2) across the cluster); the optional --prearm-receive 1 path can arm the next
receive while the previous batch is still being sent. Admission resumes
when the old application and transport owners have actually released a slot.
Windows reserves connections + 1 socket-table entries per shard and one listener across the cluster.
Secondary queues each reserve connections + 1 optional entries for connections usable positions.
Admission charges remain held across queue residence and socket adoption.
The cluster owns at most connections + 1 nonlistener socket handles, plus one listener.
Each pending accept creates a Winsock socket within that table capacity.
Provider allocations and delayed TCP cleanup remain outside the framework heap budget.
The fixed table bounds application-owned handles, rather than every kernel resource retained over time.
The demo caps requested live bytes through its framework allocator at
memory_budget_bytes - Cluster.stackBytes(config), reserving the requested
worker and secondary I/O-owner stack budget separately. Budget tracks live/peak requested bytes and
refuses allocation, resize or remap growth beyond that heap cap. Startup also
checks exact requested framework heap bytes plus requested startup stacks before allocation, including the cluster coordinator, shard arrays, every response cell and gather descriptor.
Each secondary shard and each application worker reserves worker_stack_bytes;
shard 0 uses the caller's existing stack. Each shard reserves the full slot
capacity, so heap usage multiplies with shard count even though admission is
a shared process-wide ceiling. This is not an RSS limit: allocator
metadata, libc/pthread metadata and actual stack mappings, mapped kernel rings,
socket queues, loaded assets and arbitrary application allocations require
separate accounting. Zig 0.16's pthread implementation uses its C allocator for
thread bookkeeping despite the supplied spawn allocator. The demo seals its
framework allocator after startup and counts/refuses subsequent allocation
attempts through it. See Budget for the implementation.
Worker separation keeps a finite slow handler off the I/O thread, but is not isolation from arbitrary application code. Slots have fixed worker affinity; a blocked callback delays other requests assigned to the same worker. Threads share memory and process fate. Deadlines set cancellation and close networking; they cannot safely preempt arbitrary code or recycle its borrowed buffers. If shutdown cannot recover all owners within its deadline, the demo terminates the process with exit code 70 instead of freeing storage still in use.
For a first measurement, keep the server running and use a second terminal:
python3 tools/benchmark.py http://127.0.0.1:8080/plaintext --connections 8 --requests 10000 --pipeline 16 --json .zig-cache/plaintext-smoke.json
python3 tools/benchmark.py http://127.0.0.1:8080/index.html --connections 8 --requests 1000 --expect-file assets/index.html --json .zig-cache/html-smoke.jsonThis Python client checks bodies and records throughput and closed-loop pipeline
latencies; it may be the bottleneck. It is a smoke experiment, not server
capacity or an open-loop service-level measurement. The separate
Linux contender comparison measures pinned
Round23 mrhttp/libreactor with wrk: our unchanged MVP is substantially slower,
especially under pipelining. Subsequent inline/gather and batching experiments preserve that baseline. Its CPU budget, different callback/resource
contracts and rejected tail-latency evidence are explicit.
The subsequent bounded batching report preserves
inline/gather improvements, one-core comparisons, deeper-pipeline plateaus and
run-to-run variation.
The demo emits STATS JSON on clean shutdown: connection/operation peaks,
refusals, timeouts, flush/resume counts, byte counters, maximum queue/handler/
request-cycle durations, loop processing time, pipeline bytes copied,
framework_heap_peak_bytes, framework_heap_limit_bytes and late framework
allocation attempts. It does not yet provide latency histograms,
per-reason rejection metrics or a live metrics endpoint.
Zero-copy here describes borrowed parsing and worker/transport payload handoff.
Response heads are written straight into the connection's output arena at
begin(), generated bodies follow them, and borrowed spans up to the copy
threshold are copied there too (counted as borrow_copies), so a batch of small
responses is one contiguous span and one SEND. Larger borrows stay separate
vectors of a SENDMSG and remain borrowed through terminal completion.
--gather-send 0 retains the scalar path for controlled comparison.
Ordinary socket I/O still copies across the kernel boundary; this MVP does not
use SEND_ZC, zero-copy receive or file sendfile. Pipelined suffix compaction
copies the remaining suffix once after a whole batch drains and is counted explicitly.
The Linux adapter uses raw std.os.linux.IoUring, not std.Io.Evented or
std.Io.Threaded; the kernel's own resources are outside the fixed application
worker count.
For two-binary experiments, tools/compare.py --order abba runs adjacent
A/B/B/A trials at identical connections and pipeline depth; the configuration's
first server is A. It shuffles whole blocks only. --repeats 2 means two blocks
and therefore four samples per server/workload. Receipts identify each block,
position and sample count. This reduces linear time-order bias; it does not
isolate the host or remove thermal/client variation. The default shuffled
ordering retains its original one-sample-per-repeat behavior. Use host-local
/tmp/zig-http-measurement.lock reservations for preparation and timed runs,
and finish all builds before measuring.
Two earlier experiments on the pre-arena implementation are preserved:
direct operation cells (48 paired
Linux trials and native identity/cancellation gates) and the
batch/callback matrix (24 same-binary
trials; at client depth 128, B16/Q64 median 1.951M/s versus B64/Q256 2.780M/s).
The current transport addresses every operation by cell and the arena replaces
fixed cells, so those knobs map onto --response-batch-limit (1–511) and
--callbacks-per-turn; --inline-callback-budget is accepted as an alias.
The arena/shard report records the
original implementation's preliminary Mac ladder and interleaved Linux pairs.
The adoption report tracks integration
fixes, qualified comparisons and current verification.
Reproduce all three finite smoke workloads with python3 tools/smoke.py. It
checks the running binary reports ReleaseSafe. For isolated Linux verification
from the Mac, run tools/verify_linux_ssh.sh omarx1; use a clean pushed checkout
for publication evidence.
The manual Windows workflow builds and runs native x64 tests on GitHub-hosted Windows. Its finite supervisor captures compiler, operating-system, source, binary, and test evidence. Four POSIX suspension fixtures remain explicitly excluded on Windows. Windows timings do not qualify comparative performance or physical deployment behavior.
Response batching uses the ordinary handler and writer for every request; there
is no cached plaintext response path. Each finished response is a range of the
connection's arena plus an optional borrowed span, frozen until terminal sends
release the whole batch. A batch drains at its configured limit, when the
available pipeline ends, when the arena is nearly full, on flush/close, or when
the callback budget is exhausted. It never waits for a batch to fill.
--response-batch-limit 1 gives a controlled unbatched comparison. At most
2 × limit + 1 vectors describe a batch; the aggregate --send-chunk limit
still applies. Request input stays immutable while any response borrows it.
STATS reports shards, callbacks_per_turn, single_span_send_operations
and borrow_copies; with several shards a SHARDS line lists per-shard
admission and completion counts.
Run python3 tests/arena_lifecycle_integration.py for EOF/interim ordering and
worker/inline half-close cases. Run python3 tests/batch_integration.py for distinct generated and borrowed
bodies, mixed routes, small send caps, flush/order barriers, fairness and
cancellation; tests/gather_integration.py tests transport operations separately.
The server counts completed requests and cycle maxima when their containing
batch fully drains. A successfully sent prefix of a later canceled batch may
therefore be omitted; these are conservative batch completion observations,
not exact per-request latency measurements.
The Linux wrk harness accepts pipeline depths 1,16,32,64,128; its default remains 1/16 for the original TechEmpower comparison. Deeper client pipelines do not raise the server's configured response-cell limit. Preflight validates two complete pipelines at each measured depth (at least16), followed by timed wrk framing/status/error checks. The independent batch wire suite checks distinct bodies and ordering through repeated bounded drains.
Comparison receipts record the power profile, CPU driver/governor/EPP, frequency
bounds, instantaneous endpoint frequency and Intel pstate limits before and after
each timed trial. An optional expected_power_profile configuration field rejects
an absent or changed endpoint profile. Those snapshots are outside the timed
region; they do not measure average frequency, residency, or continuous policy.