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: End-to-end testing without running a persistent server — pass raw requests directly to
handle_request()or useserver.test(...). - 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/SIGINTviaserver.shutdown(grace_ms). - Multiple Backends: epoll, io_uring (Linux), kqueue (macOS), IOCP (Windows).
- Async Handler: Suspend/resume requests on any fd (DB sockets, timers, upstream proxies) — all in the worker's event loop, zero extra threads.
- Stateful Handler: Lock-free per-worker state (e.g. a per-thread DB connection — no shared pool, no mutex).
- Compliant with HTTP standards: Follows RFC 9112 and the IANA Field Name Registry.
import http_server
fn handle_request(req_buffer []u8, client_conn_fd int, mut out []u8) ! {
// Parse the request and APPEND the complete raw HTTP response
// (status line + headers + body) to `out`. The server owns `out`,
// reuses it across requests and batches pipelined responses into a
// single send — never free or keep it.
out << 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok'.bytes()
}
fn main() {
mut backend := unsafe { http_server.IOBackend(0) }
$if linux {
backend = http_server.IOBackend.epoll
}
$if darwin {
backend = http_server.IOBackend.kqueue
}
mut server := http_server.new_server(http_server.ServerConfig{
port: 3000
request_handler: handle_request
io_multiplexing: backend
})!
server.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 out := []u8{}
handle_request(request, -1, mut out)!
assert out.starts_with('HTTP/1.1 200 OK'.bytes())
}Or use the server's built-in test mode:
mut server := http_server.new_server(http_server.ServerConfig{ ... })!
responses := server.test([request1, request2]) or { panic(err) }import http_server
import os
fn main() {
mut server := http_server.new_server(http_server.ServerConfig{ ... })!
os.signal_opt(.term, fn [server] (_ os.Signal) {
server.shutdown(2000) // drain up to 2 s, then exit
exit(0)
}) or {}
server.run()
}Run 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 server := http_server.new_server(http_server.ServerConfig{
port: 3000
io_multiplexing: backend
request_handler: fn [mut pool] (req_buffer []u8, fd int, mut out []u8) ! {
// Use pool.acquire() / pool.release() for DB access;
// append the raw HTTP response to `out`.
}
})!
server.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/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/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/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/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) |
Server.test accepts an array of raw HTTP requests, sends them directly to the server socket, and processes each one sequentially. After receiving the response for the last request, the server shuts down automatically. This enables efficient end-to-end testing without running a persistent server process.
- 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/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)
-
Hostheader validation (RFC 9112 §7.2) - 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 support (multiplexing, HPACK, server push)
- WebSocket upgrade (framing, ping/pong, close handshake)
- 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.)
