Getting Started
blitz is a micro web framework for Zig focused on raw performance and a clean API. Add it to your project in two steps.
Installation
Add blitz to your build.zig.zon:
zig fetch --save "https://github.com/BennyFranciscus/blitz/archive/main.tar.gz"
Then in your build.zig:
const blitz_dep = b.dependency("blitz", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("blitz", blitz_dep.module("blitz"));
exe.linkLibC(); // required for signal handling and io_uring
Quick Start
const std = @import("std");
const blitz = @import("blitz");
fn hello(_: *blitz.Request, res: *blitz.Response) void {
_ = res.text("Hello, World!");
}
fn greet(req: *blitz.Request, res: *blitz.Response) void {
const name = req.params.get("name") orelse "stranger";
_ = res.text(name);
}
pub fn main() !void {
var router = blitz.Router.init(std.heap.c_allocator);
router.get("/", hello);
router.get("/hello/:name", greet);
var server = blitz.Server.init(&router, .{ .port = 8080 });
try server.listen();
}
Build and run:
zig build -Doptimize=ReleaseFast
./zig-out/bin/your-app
Router
blitz uses a radix-trie router for fast path matching with support for path parameters, wildcards, and custom 404 handlers.
var router = blitz.Router.init(allocator);
// HTTP methods
router.get("/path", handler);
router.post("/path", handler);
router.put("/path", handler);
router.delete("/path", handler);
router.patch("/path", handler);
router.head("/path", handler);
router.options("/path", handler);
router.route(.PATCH, "/path", handler);
// Path parameters
router.get("/users/:id", getUserHandler);
// Wildcards
router.get("/static/*filepath", staticHandler);
// Custom 404 (or use the built-in JSON one)
router.notFound(blitz.jsonNotFoundHandler);
Request API
fn handler(req: *blitz.Request, res: *blitz.Response) void {
// Method
if (req.method == .GET) { ... }
// Path parameters
const id = req.params.get("id") orelse "unknown";
// Simple query parameter lookup (zero-copy)
const page = req.queryParam("page") orelse "1";
// Structured query parsing with typed access
const q = req.queryParsed();
const limit = q.getInt("limit", i64) orelse 20;
const asc = q.getBool("asc") orelse true;
_ = limit;
_ = asc;
// URL-decoded query param
var decode_buf: [256]u8 = undefined;
const search = q.getDecode("q", &decode_buf);
_ = search;
// Headers
const ct = req.headers.get("Content-Type");
// Body
if (req.body) |body| { ... }
}
Response API
fn handler(_: *blitz.Request, res: *blitz.Response) void {
// Plain text
_ = res.text("hello");
// JSON (raw string)
_ = res.json("{\"ok\":true}");
// HTML
_ = res.html("<h1>Hello</h1>");
// Custom status
_ = res.setStatus(.not_found).text("Not Found");
// Custom headers
res.headers.set("X-Custom", "value");
// Pre-computed raw response (maximum performance)
_ = res.rawResponse("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
}
Redirects
fn handler(_: *blitz.Request, res: *blitz.Response) void {
// Temporary redirect (302)
_ = res.redirectTemp("/login");
// Permanent redirect (301)
_ = res.redirectPerm("/new-url");
// Custom status redirect
_ = res.redirect("/other", .found);
}
Middleware
Middleware functions return true to continue or false to short-circuit (e.g., deny access).
Global Middleware
Runs on every request:
// Use the built-in CORS middleware
router.use(blitz.Cors.permissive());
// Or write your own
fn timing(_: *blitz.Request, res: *blitz.Response) bool {
res.headers.set("Server-Timing", "middleware");
return true;
}
router.use(timing);
Per-Route Middleware
Runs only on matching routes:
fn auth(req: *blitz.Request, res: *blitz.Response) bool {
if (req.headers.get("Authorization") == null) {
blitz.unauthorized(res, "Token required");
return false; // stop here
}
return true;
}
// Attach middleware to a path prefix — applies to all routes under it
router.useAt("/api", auth);
// Or attach middleware to a route group (same effect, cleaner API)
const api = router.group("/api/v1");
api.use(auth); // Only /api/v1/* routes run this
api.get("/users", listUsers);
api.get("/users/:id", getUser);
// Nested groups stack middleware
const admin = api.group("/admin");
admin.use(adminOnly); // Runs auth + adminOnly for /api/v1/admin/*
admin.get("/stats", adminStats);
Execution order: global middleware → per-route middleware (collected along the matched path) → handler.
Middleware on parent paths runs before middleware on child paths, so you can layer auth, logging, rate limiting at different levels of your route tree.
Route Groups
Groups share a URL prefix — great for versioned APIs. Prefix concatenation happens at init time with zero runtime overhead.
const api = router.group("/api/v1");
api.get("/users", listUsers); // matches /api/v1/users
api.get("/users/:id", getUser); // matches /api/v1/users/:id
api.post("/users", createUser); // matches /api/v1/users
// Nested groups
const admin = api.group("/admin");
admin.get("/stats", adminStats); // matches /api/v1/admin/stats
JSON Builder
Zero-allocation JSON serialization powered by comptime. Writes directly into caller-provided buffers.
Struct Serialization
var buf: [512]u8 = undefined;
const json_str = blitz.Json.stringify(&buf, .{
.name = "Alice",
.age = @as(i64, 30),
.active = true,
}) orelse return error.BufferOverflow;
_ = res.json(json_str);
Manual Object Building
var obj_buf: [256]u8 = undefined;
var obj = blitz.JsonObject.init(&obj_buf);
obj.field("id", @as(i64, 1));
obj.field("name", "Alice");
obj.field("tags", @as([]const []const u8, &.{ "admin", "user" }));
const body = obj.finish() orelse "{}";
_ = res.json(body);
Array Building
var arr_buf: [256]u8 = undefined;
var arr = blitz.JsonArray.init(&arr_buf);
arr.push(@as(i64, 1));
arr.push(@as(i64, 2));
arr.push(@as(i64, 3));
const list = arr.finish() orelse "[]";
Supports: structs, slices, ints, floats, bools, strings, optionals (null fields skipped), enums (as strings).
JSON Parsing
Comptime-powered JSON deserialization — parse request bodies directly into Zig structs. Zero-copy strings (slices into the original JSON input).
Basic Usage
const CreateUser = struct {
name: []const u8,
email: []const u8,
age: i64,
};
fn createUser(req: *blitz.Request, res: *blitz.Response) void {
const user = req.jsonParse(CreateUser) orelse {
blitz.badRequest(res, "Invalid JSON body");
return;
};
// user.name, user.email, user.age are ready to use
_ = res.setStatus(.created).json("{\"created\":true}");
}
Optional Fields & Defaults
const UpdateUser = struct {
name: ?[]const u8 = null, // missing key → null
age: ?i64 = null, // explicit null → null
role: []const u8 = "user", // missing key → "user"
};
const update = req.jsonParse(UpdateUser) orelse return;
if (update.name) |name| { /* name was provided */ }
Nested Structs
const Address = struct { city: []const u8, zip: []const u8 };
const Person = struct { name: []const u8, address: Address };
// Parses: {"name":"Alice","address":{"city":"NYC","zip":"10001"}}
const person = req.jsonParse(Person) orelse return;
Enums
const Status = enum { active, inactive, pending };
const Filter = struct { status: Status };
// Parses: {"status":"active"}
const filter = req.jsonParse(Filter) orelse return;
JSON Arrays
const IntList = blitz.JsonArrayParser(i64, 32); // max 32 items
const nums = IntList.parse("[1, 2, 3, 4, 5]") orelse return;
for (nums.slice()) |n| { /* ... */ }
// Array of structs
const Item = struct { id: i64, name: []const u8 };
const Items = blitz.JsonArrayParser(Item, 16);
const items = Items.parse(req.body orelse "[]") orelse return;
Standalone Parsing
// Parse any JSON string (not just request bodies)
const Config = struct { host: []const u8, port: i64 };
const cfg = blitz.JsonParser.parse(Config, json_string) orelse return;
Unknown JSON keys are silently skipped. Escaped strings (\n, \t, \uXXXX, etc.) are unescaped automatically.
Query Parsing
Structured query string parsing with typed access, URL decoding, multi-value support, and iteration.
fn search(req: *blitz.Request, res: *blitz.Response) void {
const q = req.queryParsed(); // GET /search?q=hello+world&page=2&debug
// Simple string lookup (raw, no decoding)
const term = q.get("q"); // "hello+world"
// URL-decoded value
var buf: [256]u8 = undefined;
const decoded = q.getDecode("q", &buf); // "hello world"
_ = decoded;
// Typed access
const page = q.getInt("page", i64) orelse 1; // 2
const debug = q.getBool("debug") orelse false; // false
_ = page;
_ = debug;
// Check key existence (even without value)
if (q.has("debug")) { ... }
// Multi-value params: /search?tag=zig&tag=http&tag=fast
var tags: [8][]const u8 = undefined;
const n = q.getAll("tag", &tags); // n=3
_ = n;
// Iterate all params
var it = q.iterator();
while (it.next()) |param| {
// param.key, param.value
_ = param;
}
_ = res.json("{\"ok\":true}");
}
Standalone URL Decoding
var buf: [256]u8 = undefined;
const decoded = blitz.urlDecode(&buf, "hello%20world+foo"); // "hello world foo"
Body Parsing
Parse form bodies and multipart uploads with zero-copy efficiency.
URL-Encoded Forms
fn handleForm(req: *blitz.Request, res: *blitz.Response) void {
const form = req.formData(); // parses application/x-www-form-urlencoded
const name = form.get("name") orelse "anonymous";
const age = form.getInt("age", i64) orelse 0;
_ = age;
// URL-decoded values
var buf: [256]u8 = undefined;
const msg = form.getDecode("message", &buf);
_ = msg;
_ = res.text(name);
}
Multipart/Form-Data (File Uploads)
fn handleUpload(req: *blitz.Request, res: *blitz.Response) void {
const mp = req.multipart() orelse {
blitz.badRequest(res, "Expected multipart body");
return;
};
// Get a text field
if (mp.get("title")) |part| {
// part.data is the field value
_ = part;
}
// Get a file upload
if (mp.getFile("avatar")) |file| {
// file.filename — original filename
// file.content_type — MIME type
// file.data — file contents (slice into request body)
_ = file;
}
_ = res.text("uploaded");
}
Content Type Detection
fn handler(req: *blitz.Request, res: *blitz.Response) void {
switch (req.contentType()) {
.json => { /* parse JSON body */ },
.form_urlencoded => { const form = req.formData(); _ = form; },
.multipart => { const mp = req.multipart(); _ = mp; },
else => { blitz.badRequest(res, "Unsupported content type"); return; },
}
_ = res.text("ok");
}
Static Files
Serve files from disk with automatic MIME type detection, directory traversal protection, and optional cache control.
// Serve files from ./public at /static/*
router.staticDir("/static", "./public", .{});
// With options
router.staticDir("/assets", "./dist", .{
.cache_control = "public, max-age=31536000", // immutable assets
.index = true, // serve index.html for directories
.max_file_size = 10 * 1024 * 1024, // 10MB max
});
Features:
- 40+ MIME types — HTML, CSS, JS, images, fonts, media, archives, WASM
- Path traversal protection — rejects
../, absolute paths, null bytes - Directory index — automatically serves
index.htmlfor directory paths - Cache-Control — optional header for browser caching
- GET/HEAD only — other methods fall through to route matching
WebSocket
Full RFC 6455 WebSocket support for building real-time applications.
const blitz = @import("blitz");
const ws = blitz.WebSocket;
fn handleWsUpgrade(req: *blitz.Request, res: *blitz.Response) void {
// Check if this is a WebSocket upgrade request
if (!ws.isUpgradeRequest(req)) {
_ = res.setStatus(.bad_request).text("Expected WebSocket upgrade");
return;
}
// Get the client's key
const key = req.header("Sec-WebSocket-Key") orelse return;
// Build and send the 101 Switching Protocols response
var buf: [512]u8 = undefined;
const upgrade_resp = ws.buildUpgradeResponse(&buf, key, null) orelse return;
_ = res.rawResponse(upgrade_resp);
// After upgrade, use ws.parseFrame() and ws.buildFrame() for communication
}
Frame operations:
ws.parseFrame(data)— parse incoming frames (auto-unmasks client data)ws.buildFrame(buf, opcode, payload, fin)— build outgoing frames (text, binary, ping, pong)ws.buildCloseFrame(buf, code, reason)— build close frame with status code
Opcodes: .text, .binary, .close, .ping, .pong, .continuation
Close codes: .normal, .going_away, .protocol_error, .too_large, etc.
Compression
blitz automatically compresses responses with gzip or deflate when the client supports it. Compression is enabled by default.
var server = blitz.Server.init(&router, .{
.port = 8080,
.compression = true, // default — automatic gzip/deflate
});
// Disable compression (e.g., for benchmarks)
var server2 = blitz.Server.init(&router, .{
.compression = false,
});
How it works:
- After each handler runs, blitz checks
Accept-Encodingand compresses the body if beneficial - Uses Zig's
std.compress.gzip(fast level) for minimal latency overhead - Adds
Content-Encoding: gzipandVary: Accept-Encodingheaders automatically - If compressed output is larger than the original, compression is skipped
- Pre-computed
rawResponse()calls bypass compression (benchmark fast path) - Minimum body size: 256 bytes
Manual Compression
const blitz = @import("blitz");
fn handler(req: *blitz.Request, res: *blitz.Response) void {
_ = res.json(large_json_string);
// Check if compression is worthwhile
if (blitz.shouldCompress(req, res)) {
var buf: [65536]u8 = undefined;
_ = blitz.compressResponse(&buf, req, res);
}
}
Rate Limiting
Token-bucket rate limiter with per-IP tracking and standard HTTP headers.
// Initialize the rate limiter
var limiter = try blitz.RateLimiter.init(allocator, .{
.max_requests = 100, // requests per window
.window_secs = 60, // window duration
.max_clients = 4096, // max tracked IPs
});
// Use in handlers
fn myHandler(req: *blitz.Request, res: *blitz.Response) void {
if (!blitz.RateLimit.allow(&limiter, req, res)) return;
// ... handle request normally
}
The rate limiter extracts client IPs from X-Forwarded-For or X-Real-IP headers (for reverse proxy setups), and sets X-RateLimit-Remaining and Retry-After headers automatically.
CORS
Built-in CORS middleware with configurable origins, methods, and preflight handling.
// Permissive CORS — allow all origins
router.use(blitz.Cors.permissive());
// Configured CORS — specific origins with credentials
router.use(blitz.Cors.middleware(.{
.origins = &.{ "https://myapp.com", "http://localhost:3000" },
.allow_credentials = true,
.headers = "Content-Type, Authorization, X-Request-ID",
.max_age = 3600,
.max_age_str = "3600",
}));
The CORS middleware automatically handles OPTIONS preflight requests (returns 204 with appropriate headers) and sets Access-Control-Allow-Origin, Vary, and Access-Control-Allow-Credentials headers on all responses.
When allow_credentials is true, the middleware echoes the request's Origin header instead of using * (as required by the spec).
Context Injection
Pass application state (database connections, config, services) to handlers without global variables.
const AppState = struct {
db: *Database,
config: *AppConfig,
};
fn getUsers(req: *blitz.Request, res: *blitz.Response) void {
const app = req.context(AppState);
const users = app.db.query("SELECT * FROM users");
// ...
}
pub fn main() !void {
var state = AppState{ .db = &db, .config = &config };
var router = blitz.Router.init(std.heap.c_allocator);
router.get("/users", getUsers);
var server = blitz.Server.init(&router, .{
.port = 8080,
.context = @ptrCast(&state),
});
try server.listen();
}
The context is set once via Server.Config.context and automatically injected into every request. Access it in any handler with req.context(T) which returns a typed pointer.
Works with both epoll and io_uring backends. The context pointer is shared across all threads — ensure your state is either read-only or thread-safe (use atomics, mutexes, or per-thread copies).
Error Handling
Structured JSON error responses with convenience helpers.
fn getUser(req: *blitz.Request, res: *blitz.Response) void {
const id = req.params.get("id") orelse {
blitz.badRequest(res, "Missing user ID");
return;
};
// ... look up user ...
blitz.notFound(res, "User not found");
}
// Available error helpers:
blitz.badRequest(res, "message"); // 400
blitz.unauthorized(res, "message"); // 401
blitz.forbidden(res, "message"); // 403
blitz.notFound(res, "message"); // 404
blitz.methodNotAllowed(res, "msg"); // 405
blitz.internalError(res, "message"); // 500
// Generic:
blitz.sendError(res, .bad_request, "Custom message");
// Response format: {"error":{"status":400,"message":"Missing user ID"}}
// Built-in JSON 404 handler for the router:
router.notFound(blitz.jsonNotFoundHandler);
Logging
Built-in structured request logging with zero allocations — writes directly to stderr.
var server = blitz.Server.init(&router, .{
.port = 8080,
.logging = .{
.enabled = true,
.format = .text, // .text or .json
.min_level = .info, // .debug, .info, .warn, .err, .off
.slow_threshold_ms = 500, // warn on slow requests (0 = disabled)
},
});
Text Format
INFO GET /api/users?page=1 200 1.2ms 256B
WARN POST /api/login 401 0.3ms 45B
ERROR GET /crash 500 15.7ms 128B
JSON Format
{"level":"INFO","method":"GET","path":"/api/users","query":"page=1","status":200,"latency_us":1200,"size":256}
Log levels are auto-determined from response status: 5xx → ERROR, 4xx → WARN, 2xx/3xx → INFO.
Slow request detection: Set slow_threshold_ms to log requests exceeding the threshold even if their status-based level is below min_level.
General-Purpose Logging
blitz.logMsg(config, .info, "server started on port 8080");
blitz.logMsg(config, .warn, "connection pool exhausted");
Logging is disabled by default — zero overhead when not configured. All formatting uses stack buffers (no heap allocations).
Graceful Shutdown
blitz handles SIGTERM and SIGINT automatically:
- Stops accepting new connections immediately
- Finishes in-flight requests and flushes responses
- Sends
Connection: closeto signal clients - Drains for up to
shutdown_timeoutseconds - Force-closes remaining connections if timeout expires
Works correctly as PID 1 in Docker containers. Check blitz.isShuttingDown() in middleware or handlers to detect shutdown in progress.
// Middleware that rejects new work during shutdown
fn shutdownAware(req: *blitz.Request, res: *blitz.Response) bool {
if (blitz.isShuttingDown()) {
_ = res.setStatus(.service_unavailable).text("Shutting down");
return false;
}
return true;
}
Server Configuration
var server = blitz.Server.init(&router, .{
.port = 8080,
.threads = null, // auto-detect CPU count
.keep_alive_timeout = 60, // seconds (0 = disable)
.shutdown_timeout = 30, // seconds to drain before force-close
});
try server.listen(); // Blocks until SIGTERM/SIGINT
SQLite Integration
Built-in SQLite wrapper via @cImport — zero overhead, per-thread connections, prepared statements with typed column access.
Opening a Database
const blitz = @import("blitz");
// Open with read-only + mmap for maximum read performance
var db = try blitz.SqliteDb.open("/data/app.db", .{
.readonly = true,
.mmap_size = 64 * 1024 * 1024, // 64MB mmap
.wal = true, // WAL journal mode
});
Prepared Statements
var stmt = try db.prepare(
"SELECT id, name, price FROM items WHERE price BETWEEN ?1 AND ?2 LIMIT 50"
);
defer stmt.finalize();
// Bind parameters (1-indexed)
try stmt.bindDouble(1, min_price);
try stmt.bindDouble(2, max_price);
// Iterate results
while (try stmt.step()) {
const id = stmt.columnInt(0); // i64
const name = stmt.columnText(1); // []const u8 (zero-copy, valid until next step/reset)
const price = stmt.columnDouble(2); // f64
// ... process row
}
// Reset for reuse (keeps compiled query plan)
stmt.reset();
Column Types
// Type checking
const col_type = stmt.columnType(0); // .integer, .float, .text, .blob, .null_type
// Null checking
if (stmt.columnIsNull(3)) { ... }
// Blob data
const data = stmt.columnBlob(4); // []const u8
Build Setup
Requires libsqlite3-dev at build time:
// build.zig
exe.linkSystemLibrary("sqlite3");
io_uring Backend
For maximum throughput, blitz includes an io_uring backend:
// In your main.zig:
var uring_server = blitz.UringServer.init(&router, .{
.port = 8080,
.threads = null, // auto-detect
.compression = false, // for benchmarks
});
try uring_server.listen();
Or use the environment variable:
BLITZ_URING=1 ./blitz
Architecture
- Dedicated acceptor thread — single io_uring ring with multishot accept, distributes connections round-robin
- SPSC queue fd handoff — lock-free single-producer/single-consumer queue (cache-line aligned) per reactor thread for zero-contention fd distribution
- Reactor threads — each has its own io_uring ring dedicated to recv/send (no accept overhead)
- Kernel-managed buffer ring (
io_uring_buf_ring) — 4096 pre-allocated recv buffers, zero-SQE buffer recycling via shared memory - Zero-copy send (
send_zc) — eliminates kernel buffer copy for response writes (kernel 6.0+, auto-fallback) - Async send — non-blocking response writes with partial-send resubmission
- SINGLE_ISSUER + DEFER_TASKRUN — reduced kernel overhead on reactor rings (auto-fallback for older kernels)
- Connection pooling — per-reactor pre-allocated ConnState pool (4096 slots) with O(1) acquire/release
Requirements: Linux 5.19+ (6.0+ for zero-copy send, 6.1+ for DEFER_TASKRUN). Docker containers need --privileged or appropriate seccomp profile.
Architecture
├── blitz.zig # Module root — re-exports everything
├── blitz/
│ ├── types.zig # Request, Response, Method, StatusCode, Headers
│ ├── router.zig # Radix-trie router with middleware, groups, params & wildcards
│ ├── parser.zig # Zero-copy HTTP/1.1 request parser
│ ├── server.zig # Epoll event loop, connection management, graceful shutdown
│ ├── uring.zig # io_uring backend — acceptor + reactor threads
│ ├── spsc.zig # Lock-free SPSC queue for cross-thread fd handoff
│ ├── pool.zig # Connection pool — pre-allocated ConnState objects
│ ├── query.zig # Query string parser with URL decoding and typed access
│ ├── json.zig # Comptime JSON serializer (Json, JsonObject, JsonArray)
│ ├── body.zig # Request body parsing (URL-encoded, multipart)
│ ├── cookie.zig # Cookie parsing and Set-Cookie builder (RFC 6265)
│ ├── compress.zig # Response compression (gzip/deflate)
│ ├── errors.zig # Structured error responses
│ ├── static.zig # Static file serving (MIME detection, path security)
│ ├── websocket.zig # WebSocket frames, handshake, close codes (RFC 6455)
│ └── tests.zig # Unit tests for all modules
├── main.zig # HttpArena benchmark entry point
examples/
└── hello.zig # Example app with all features
Design Decisions
- No allocations in hot path — responses written to pre-allocated buffers
- Edge-triggered epoll — fewer syscalls than level-triggered
- SO_REUSEPORT — kernel distributes connections across worker threads
- Pre-computed responses — full HTTP response built at startup for static data
- Radix trie over hash map — better cache locality for path matching
- Layered middleware — global + per-route;
fn(*Req, *Res) boolis simpler and faster than callback chains - Route groups — prefix concatenation at init time, zero runtime overhead
- Comptime JSON — Zig's comptime introspects struct fields at compile time, no reflection cost at runtime
- Connection pool — pre-allocated ConnState per worker thread, O(1) acquire/release
- Keep-alive timeout — timerfd-based idle connection sweep
- Body discard mode — large uploads (>64KB) are counted but not buffered, reducing memory from O(body_size × connections) to near-zero
- SIMD HTTP parsing —
@Vector(16, u8)accelerated scanning for header boundaries and byte search
Benchmarks
Benchmarked on HttpArena — 64-core AMD Threadripper, dedicated hardware, io_uring backend.
| Profile | Connections | Throughput | Memory |
|---|---|---|---|
| Baseline | 4096 | 3.06M req/s | 4.1 GiB |
| Baseline | 16384 | 2.77M req/s | 4.2 GiB |
| Pipelined (p=16) | 4096 | 38.9M req/s | 4.1 GiB |
| JSON (8.4KB body) | 16384 | 1.66M req/s | 3.8 GiB |
| Upload (20MB body) | 64 | 1,117 req/s | 4.1 GiB |
| Compression (gzip) | 4096 | 95K req/s | 4.5 GiB |
| Noisy (mixed traffic) | 4096 | 1.99M req/s | 4.1 GiB |
| Limited-conn (r=10) | 4096 | 1.38M req/s | 4.1 GiB |
| WebSocket echo (p=16) | 4096 | 50.2M msg/s | 4.1 GiB |
#3 on HttpArena baseline, behind ringzero (C, io_uring) at 3.43M and h2o (C) at 3.16M. Ahead of nginx (3.03M), hyper/Rust (2.94M), actix/Rust (2.71M), and drogon/C++ (2.25M).