A minimalist, high-performance HTTP server written in V.
- Fast: Multi-threaded, non-blocking I/O, lock-free, copy-free, I/O multiplexing,
SO_REUSEPORT(native load balancing on Linux) - Modular: Easy to extend with custom controllers and handlers.
- Memory Safety: No race conditions.
- No Magic: Transparent and straightforward.
- E2E Testing: Test handlers in-process by passing raw requests directly to
handle_request(), or drive a running server — TCP or unix socket — with thevtestscripted client (raw fds viatransport.dial_tcp/dial_unix; seetests/backend_behaviors_test.v). - SSE Friendly: Built-in Server-Sent Events support (sync and async).
- ETag Friendly: Conditional GETs with
ETagandIf-None-Matchheaders. - Database Friendly: Example with PostgreSQL connection pool.
- Graceful Shutdown: Drain in-flight requests on
SIGTERM/SIGINTviasrv.shutdown(grace_ms). - Multiple Backends: epoll, io_uring (Linux), kqueue (macOS), IOCP (Windows).
- Local IPC: listen on a unix domain socket (
ServerConfig.unix_socket_path) instead of TCP — ≈3× lower RTT than TCP loopback, filesystem permissions as access control, kernel-verified peer identity (socket.peer_cred: pid/uid/gid viaSO_PEERCRED/getpeereid); dial other local services withtransport.dial_unix/dial_tcp. - One Handler Contract: a single
handlersignature covers every use case, with every input as an explicit, self-describing parameter — append the response and return.done, suspend/resume on any fd (DB sockets, timers, upstream proxies) withevent_loop.watch_fd(...)+.suspend, and reach lock-free per-worker state (e.g. a per-thread DB connection — no shared pool, no mutex) via theworker_stateparameter. - Compliant with HTTP standards: Follows RFC 9112 and the IANA Field Name Registry. A dedicated
examples/conformance/handler is probed in CI by h1spec and Http11Probe — see Conformance Testing.
import server
import core
fn handle_request(request []u8, mut response []u8, client_fd int, worker_state voidptr, mut event_loop core.EventLoop) core.Step {
// Parse the request and APPEND the complete raw HTTP response
// (status line + headers + body) to `response`. The server owns it,
// reuses it across requests and batches pipelined responses into a
// single send — never free or keep it. Return `.done` when the
// response is complete, `.close` to flush-and-drop the connection, or
// `.suspend` after parking the request via `event_loop.watch_fd(...)`.
response << 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok'.bytes()
return .done
}
fn main() {
mut backend := unsafe { server.IOBackend(0) }
$if linux {
backend = server.IOBackend.epoll
}
$if darwin {
backend = server.IOBackend.kqueue
}
mut srv := server.new_server(server.ServerConfig{
port: 3000
handler: handle_request
io_multiplexing: backend
})!
srv.run()
}Call the handler directly — no server needed:
fn test_handle_request() {
request := 'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n'.bytes()
mut response := []u8{}
mut event_loop := core.EventLoop{}
assert handle_request(request, mut response, -1, unsafe { nil }, mut event_loop) == .done
assert response.starts_with('HTTP/1.1 200 OK'.bytes())
}Or drive a running server over a real client socket — the vtest module owns
the whole lifecycle (ephemeral bind or unix socket, readiness, shutdown) and
dials raw non-blocking fds through transport, with scripts as data. This
exercises the full framing / keep-alive / suspend-resume path and
never hangs on a stalled stream. See
tests/backend_behaviors_test.v
for the pattern (pipelining, framing across TCP segments, timeouts, graceful
shutdown) and the *_end_to_end_test.v files under examples/ for
per-app end-to-end tests.
import server
import os
fn main() {
mut srv := server.new_server(server.ServerConfig{ ... })!
os.signal_opt(.term, fn [srv] (_ os.Signal) {
srv.shutdown(2000) // drain up to 2 s, then exit
exit(0)
}) or {}
srv.run()
}run() blocks in the accept loop, so there is no "server is up" return to hook
onto. after_server_start fills that gap: a callback that fires once, on the
main thread, the moment every listener is bound and the workers are spawned —
right before run() blocks. Works on every backend (epoll / io_uring / kqueue /
IOCP). Use it to log readiness, register in service discovery, write a
PID/health/ready file, notify a supervisor, or — in tests — signal a channel so a
client proceeds the instant the server is ready instead of polling for it:
ready := chan bool{cap: 1}
mut srv := server.new_server(server.ServerConfig{
handler: handle_request
after_server_start: fn [ready] () {
ready <- true
}
})!
spawn fn [mut srv] () {
srv.run()
}()
_ := <-ready // deterministic readiness — the server is now acceptingRun the example:
v -prod run examples/sseSubscribe (front-end):
<script>
const es = new EventSource("http://localhost:3000/events");
es.onmessage = e => document.body.innerHTML += `<p>${e.data}</p>`;
</script>Broadcast a message:
curl -X POST http://localhost:3000/broadcastcurl -v http://localhost:3000/user/1
curl -v -H "If-None-Match: c4ca4238a0b923820dcc509a6f75849b" http://localhost:3000/user/1Start the database:
docker-compose -f examples/database/docker-compose.yml up -dRun the server:
v -prod run examples/databaseExample handler (pool captured via closure):
fn main() {
mut pool := new_connection_pool(pg.Config{ ... }, 5) or { panic(err) }
mut srv := server.new_server(server.ServerConfig{
port: 3000
io_multiplexing: backend
handler: fn [mut pool] (request []u8, mut response []u8, client_fd int, worker_state voidptr, mut event_loop core.EventLoop) core.Step {
// Use pool.acquire() / pool.release() for DB access;
// append the raw HTTP response to `response`.
return .done
}
})!
srv.run()
}| Directory | Description |
|---|---|
examples/tiny/ |
Minimal "Hello, World!" — the benchmark target |
examples/simple/ |
Basic CRUD routing |
examples/simple2/ |
CRUD with helper utilities |
examples/simple3/ |
CRUD with a response builder |
examples/auth/ |
Argon2id password hashing (RFC 9106), JWT with exp (HMAC-SHA256), API key auth |
examples/chunked_streaming/ |
Chunked transfer encoding |
examples/compression/ |
Accept-Encoding negotiation over precompressed brotli/zstd/gzip const responses |
examples/conformance/ |
RFC 9112/9110-conformant handler (rejects malformed requests with the right 4xx/5xx); probed in CI by h1spec + Http11Probe |
examples/cookies_sessions/ |
Cookie-based sessions |
examples/cors/ |
CORS preflight and origin allowlist |
examples/csrf/ |
CSRF token protection |
examples/database/ |
PostgreSQL connection pool |
examples/date_header/ |
RFC 7231 Date header (shared cache, zero-alloc hot path) |
examples/efficient_date/ |
Cached Date header (per-worker, lazy 1×/s refresh) |
examples/etag/ |
ETag and conditional requests |
examples/graceful_shutdown/ |
SIGTERM/SIGINT drain |
examples/hexagonal/ |
Hexagonal architecture |
examples/ip_block/ |
IP allowlist / blocklist |
examples/json_api/ |
JSON API with multipart upload |
examples/mesh/ |
Local mesh: edge on TCP calling a backend on UDS via http1_1.client + a pooled per-worker connection + watch/suspend |
examples/middleware/ |
Middleware chain (auth, RBAC, 404) |
examples/observability/ |
/healthz, /readyz, /metrics |
examples/proxy_aware/ |
X-Forwarded-For / real-IP extraction |
examples/rate_limit/ |
Token-bucket rate limiting |
examples/redirects/ |
301/303/308 redirects |
examples/request_limits/ |
413/431 body and header size limits |
examples/security_headers/ |
HSTS, CSP, and other security headers |
examples/sse/ |
Server-Sent Events (sync broadcast) |
examples/spa_static_assets/ |
CSR/WASM SPA bundle (application/wasm, .br/.gz, immutable caching, SPA fallback) |
examples/static_files/ |
Static file serving (MIME, Range, ETag, traversal safety) |
examples/url_form/ |
Query-string and URL-encoded form parsing |
examples/veb_like/ |
veb-style declarative routing |
examples/websocket_echo/ |
RFC 6455 WebSocket echo over the connection-takeover seam (core.queue_takeover — one engine, two protocols on one connection) |
examples/http2_cleartext/ |
HTTP/2 (cleartext, prior-knowledge, RFC 9113) over the same seam — the PRI * preface flips the connection, then the SAME handler serves h1 and http2 requests |
examples/video_stream/ |
HTTP video streaming |
examples/async_sse/ |
SSE via async handler (suspend/resume on fd) |
examples/async_db_pg/ |
PostgreSQL queries via async handler |
examples/async_timer/ |
Async per-request timer |
examples/io_uring_demo/ |
io_uring backend demonstration (Linux) |
Two layers, no bespoke test mode on the server:
- In-process — call the handler directly (
handle_request(req, mut out, ...)) and assert on the bytes it appends. Deterministic, no sockets, no threads; ideal for routing and response-shape assertions. - Over a real socket — drive the server with
vtest(scripts as data, lifecycle owned by the harness) or dial raw fds yourself withtransport.dial_tcp/dial_unix+testkit's deadline-boundedfd_*readers (so a broken stream fails fast instead of hanging). Either way this drives the real backend end to end — epoll / io_uring / kqueue — including pipelining, request framing across TCP segments, keep-alive,Expect: 100-continue, half-close, read timeouts, and the async suspend/resume path. Seetests/backend_behaviors_test.vand the*_end_to_end_test.vfiles underexamples/.
examples/conformance/ is a handler written to be
correct under an HTTP/1.1 conformance probe rather than to show off a feature:
it calls the stdlib request_parser.validate_http1() plus the field-syntax and
framing checks in validate.v, so
malformed requests get the RFC-mandated status instead of being served as valid.
Two probes drive it from CI — h1spec
(RFC 9112/9110, every push) and Http11Probe
(~215 tests incl. request-smuggling, on merge).
The scorecard below is the live h1spec --strict result, rewritten by CI on
every merge to main — 🟢 passed, ⚪ blocked (no response — transient or a
tracked backend edge), 🔴 a real conformance gap. Both the deterministic
v test examples/conformance/src layer and the live h1spec probe gate the
build: a merge is blocked by any handler-decision regression and by any 🔴 real
conformance failure over a live socket. (⚪ blocked does not gate — it can be
socket-timing noise on a hosted runner, and the v test layer already asserts
those decisions.)
Live h1spec --strict scorecard — 33/33 of the checks that get an answer pass.
🟢 33 pass
Tip
Fully conformant. Every h1spec --strict check passes over a live socket — #103 is fixed, so the probe is now a hard gate.
Request line — RFC 9112 §3
| Check | ||
|---|---|---|
| 🟢 | Simple GET accepted | pass |
| 🟢 | POST with Content-Length body | pass |
| 🟢 | OPTIONS * request-target accepted | pass |
| 🟢 | Absolute-form request-target accepted | pass |
| 🟢 | CONNECT authority-form accepted | pass |
| 🟢 | Invalid HTTP version rejected | pass |
| 🟢 | Malformed request line rejected | pass |
Headers — RFC 9112 §5
| Check | ||
|---|---|---|
| 🟢 | Missing Host header rejected | pass |
| 🟢 | Duplicate Host rejected | pass |
| 🟢 | Invalid Host value rejected | pass |
| 🟢 | Invalid header name rejected | pass |
| 🟢 | Obsolete line folding rejected | pass |
| 🟢 | Space before colon rejected | pass |
| 🟢 | Null byte in header rejected | pass |
Body — RFC 9112 §6–7
| Check | ||
|---|---|---|
| 🟢 | Chunked encoding accepted | pass |
| 🟢 | Chunked + HTTP/1.0 rejected | pass |
| 🟢 | Chunked + Content-Length rejected | pass |
| 🟢 | Chunked + Content-Length closes connection | pass |
| 🟢 | Unknown transfer-coding rejected | pass |
| 🟢 | Chunked not-final coding rejected | pass |
| 🟢 | Invalid Content-Length rejected | pass |
| 🟢 | Conflicting Content-Length rejected | pass |
| 🟢 | Invalid chunk-size rejected | pass |
| 🟢 | Missing chunk terminator rejected | pass |
| 🟢 | Expect: 100-continue handling | pass |
Response semantics — RFC 9110
| Check | ||
|---|---|---|
| 🟢 | HEAD response has no body | pass |
| 🟢 | Error response is self-delimiting | pass |
Connection — RFC 9112 §9
| Check | ||
|---|---|---|
| 🟢 | Keep-alive default (HTTP/1.1) | pass |
| 🟢 | Connection: close honored | pass |
| 🟢 | HTTP/1.0 closes by default | pass |
Hardening — implementation-defined limits
| Check | ||
|---|---|---|
| 🟢 | Oversized request line | pass |
| 🟢 | Header flood | pass |
| 🟢 | Oversized header | pass |
h1spec --strict, live socket · commit f08d600 · run log · regenerated by CI on every merge
Live-probe pass/blocked split shifts run to run (the #103 half-close teardown is timing-dependent); the v test gate and examples/conformance/README.md are the stable references. Two tracked core gaps: #103 (half-close) and #104 (CL+TE framing).
- Create the target directory:
mkdir -p ~/.vmodules/enghitalo/vanilla- Copy this repository into it:
cp -r ./ ~/.vmodules/enghitalo/vanilla- Run an example:
v -prod crun examples/simplev install https://github.com/enghitalo/vanilla# Basic throughput
wrk -H 'Connection: keep-alive' --connections 512 --threads 16 --duration 30s http://localhost:3000
# Conditional GET (ETag)
wrk -t16 -c512 -d30s -H "If-None-Match: c4ca4238a0b923820dcc509a6f75849b" http://localhost:3000/user/1See BENCHMARK_RESULTS_MACOS.md for full benchmark results on Apple M4.
| Resource | Description |
|---|---|
| Wiki | Architecture deep-dives, async reactor, memory management under -gc none, Postgres pipelining, and lessons learned |
| docs/ARCHITECTURE.md | The module tree, the one-direction dependency rule between modules, and where new protocols/platforms land |
| docs/BEST_PRACTICES.md | How to write handlers, build responses, allocate, handle concurrency, security, testing, and benchmarking |
| docs/V_PERF_TOOLBOX.md | V performance attributes, array flags, the C escape hatch, profiling allocations, and known gotchas |
| docs/PERF_GAP_ANALYSIS.md | Comparison against the fastest HTTP servers (tokio, io_uring C, Zig, Rust) and what was done to close the gaps |
| CONTRIBUTING.md | Rules, raw-request testing with netcat/socat, benchmarking commands |
| CHECKLIST.md | Full improvement backlog with phases, priorities, and progress tracking |
- Per-worker
SO_REUSEPORTaccept on epoll — eliminate the single central accept thread (the io_uring backend already does per-worker accept; epoll still round-robins fds from one acceptor). Blocked by clean multi-server shutdown lifecycle. - Dynamic route matching (
/user/:id) with a trie or radix tree - Query-string parser (
?key=value&…) as a zero-copy slice view - Case-insensitive header lookup (IANA registry compliance) —
get_header_value_slice/count_headerfold ASCII case -
Hostheader validation (RFC 9112 §3.2) —validate_http1()(exactly-one Host); demonstrated end-to-end inexamples/conformance/ - Reject
Content-Length+Transfer-Encodingat the framing layer (#104) — the smuggling case the conformance handler can't fix alone - Flush a queued response before tearing down a half-closed connection (#103) — unblocks the live h1spec/Http11Probe gate
- Request timeouts —
Limits.read_timeout_ms(408) /write_timeout_ms, enforced by the per-worker deadline sweep - Chunked transfer-encoding in the request parser (
frame_chunked_total) - HTTP/2 — cleartext prior-knowledge via the takeover seam: HPACK (RFC 7541, Appendix C-verified), multiplexed streams, send-side flow control (
http2/+examples/http2_cleartext/); TLS/ALPN and the HTTP/1.1 Upgrade handshake still open - WebSocket upgrade (fram
81C0
ing, ping/pong, close handshake) —
websocket/codec +examples/websocket_echo/over the takeover seam - TLS/HTTPS — epoll backend via
ServerConfig.tls_config(e.g.tls.new_self_signed()); other backends are plaintext - HTTPS example (
examples/https/) - Body-size cap + max-connections via
Limits(max_body_bytes→ 413,max_request_bytes,max_connections); a per-connection request-count limit is still open - Response caching layer (ETag +
Last-Modifiedauto-generation) - Logging middleware example (
examples/logging/) - API documentation (godoc-style, inline)
- Architecture documentation (per-module design notes)
- Security best-practices guide (injection, timing, header limits)
- Performance tuning guide (
-gc none,taskset,ulimit, kernel parameters) - Example READMEs for every
examples/directory - Backend stress tests (high-concurrency, FD exhaustion, partial send/recv)
- Request-parser edge-case tests (malformed requests, split TCP segments)
- End-to-end integration test suite across all backends
Limitations in the V compiler and standard library that vanilla's hot paths
exercised. We filed them upstream; as of the pinned V master build
(badd3466…) every one is fixed. Kept here as a record — and as a guide to
what the current pin buys and which workarounds it retires.
-
[]T{}allocated even atlen == 0, cap == 0— fixed:__new_arraynow guards oncap > 0, so a zero-length/zero-cap literal or default-initialized array field no longer callsalloc_array_data. The append-or-constworkaround is no longer needed. (vlang/v#27487) - GC allocation did not scale across cores — fixed by thread-local
allocation: Boehm's
GC_mallocno longer takes a process-global lock, so N workers allocate concurrently instead of serializing (16 cores ≈ 16×, not ≈ 1×). This removes the GC-lock penalty that was the main reason for-gc none;-gc noneis still used where the hot path is already alloc-free. (vlang/v#27488, #27486) -
error()boxed aMessageErroron every call — fixed: builtin now exportserror_sentinel, a cached allocation-freeIError; a hot "not found"!Tpath canreturn error_sentinel(likenonefor?T) instead of allocating. (TheOk-side Result construction is a separate cost — addressed in vanilla by the plain-intframing twinframe_request_length_lim_idx.) (vlang/v#27508) - No zero-alloc integer formatter in the stdlib — fixed:
strconv.write_dec(n i64, mut buf []u8)andwrite_dec_u(n u64, …)write decimal digits into a caller-provided buffer with no allocation — use these instead of.str()/${}on the response hot path. (vlang/v#27509) -
array.slice()marked the source buffer on every call — closed: V added a.noslicesarray flag, buta[start..]still marks by default, so vanilla keeps its hand-built non-markingbuf_viewwindow — now used by both the epoll and io_uring backends. (vlang/v#27507) -
&Struct{}in anif-expression branch miscompiled in some build modes — fixed in cgen; the statement-form (mut x := &T(unsafe{nil}); if … {}) workaround is no longer required. (vlang/v#27329) - stdlib formatter / KDF gaps —
strings.Builder.write_decimalgained an unsignedu64variant + JS-backend parity (vlang/v#27510); bcrypt/scrypt/pbkdf2 are now documented in the crypto README (vlang/v#27511). runtime.nr_cpus()ignores CPU affinity (not a V change — handled vanilla-side): it issysconf, blind totaskset/cpuset, so on a pinned or CPU-capped host it over-counts. vanilla sizes its pool fromcore.worker_count()=VANILLA_WORKERS→nr_cpus(); setVANILLA_WORKERSto pin the worker count inside a cpuset or CPU-limited container. (An affinity-aware auto-count was tried and reverted — it under-sized the DB profiles.)