Skip to content

Server API ​

The httpx.zig server module provides a robust HTTP server with middleware support, routing, and proper context handling. The high-level runtime supports HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3.

Protocol Support ​

ProtocolStatusFeatures
HTTP/1.0✅ FullBasic request/response
HTTP/1.1✅ FullKeep-Alive, chunked transfer, trailers
HTTP/2✅ FullHigh-level server runtime over TCP/TLS plus full framing/HPACK/stream primitives
HTTP/3✅ Reliable pathsHigh-level server runtime over UDP + QUIC/TLS 1.3 with loss recovery, NewReno congestion control, 0-RTT, Retry, and v1/v2 version negotiation; the high-level client reuses pooled connections sequentially, and connection migration is not implemented

Server ​

The Server struct manages the listener, router, and middleware processing.

Initialization ​

zig
const std = @import("std");
const httpx = @import("httpx");

const io = std.Io.Threaded.global_single_threaded.io();

// Initialize with default config
var server = try httpx.Server.init(allocator, io, .{});
defer server.deinit();

// Initialize with custom config
var custom_server = try httpx.Server.init(allocator, io, .{
    .host = "127.0.0.1",
    .port = 3000,
    .portStrategy = .incremental,
    .maxPortAttempts = 32,
    .maxBody = 1024 * 1024, // 1MB
});
defer custom_server.deinit();

// Initialize with environment variables (PORT, HOST, HTTPX_WORKERS)
var env_server = try httpx.Server.init(allocator, io, httpx.Server.Config.fromEnv());
defer env_server.deinit();

Configuration (inline .{} options) ​

FieldTypeDefaultDescription
host[]const u8"0.0.0.0"Interface to bind to.
portu168080Port to listen on (0 = ephemeral; read back via localPort()).
portStrategyPortStrategy.incrementalStartup behavior when the port is occupied (.incremental, .strict, .exit).
maxPortAttemptsu1650Port candidates to try when portStrategy = .incremental.
maxBodyusize8MBLargest accepted request body.
maxConnectionsusize0Total connections successfully queued before run() stops accepting (0 = unlimited).
maxConcurrentConnectionsusize128Bound across connections being processed or queued. New connections above the limit are closed. Must be greater than zero.
maxQueuedConnectionTasksusize128Bounded worker queue capacity. Newly accepted sockets are closed when the queue is full. Must be greater than zero.
workerCountu160Fixed worker count; zero selects up to 16 CPUs, capped by maxConcurrentConnections.
initialRequestTimeoutMsu325000Maximum time from accept to the first request bytes and protocol sniff. Zero disables the deadline.
headerReadTimeoutMsu3210000Absolute time to finish request headers after the first byte. Zero disables the deadline.
requestBodyTimeoutMsu3230000Absolute time to receive a complete body. Zero disables the deadline.
writeTimeoutMsu3230000Absolute time allowed for an interim or final response write. Zero disables the deadline.
keepAliveIdleTimeoutMsu3230000Maximum wait for the next request on an HTTP/1 keep-alive connection. Zero disables the deadline.
maxRequestsPerConnusize1000Max requests served per keep-alive connection.
keepAliveboolfalseHonor HTTP/1.1 keep-alive on connections.
compressioncompression.Config{}Automatic response compression codec settings for HTTP/1.1, HTTP/2, and HTTP/3.
maxCompressionInputusize128 KiBLargest response body the server will compress automatically.
allowLfLineEndingsboolfalseAccept bare LF line endings when parsing requests.
trustForwardedHeadersboolfalseTrust X-Forwarded-* headers from reverse proxies.
trustedProxies[]const []const u8&.{}IPs/CIDRs allowed to supply forwarded headers; forwarded chains skip entries matching these rules. Empty trusts every peer when forwarding is enabled.
httpVersion?HttpVersionnullPreferred HTTP version enforcement.
http10booltrueEnable HTTP/1.0 handling.
http11booltrueEnable HTTP/1.1 handling.
http2booltrueHTTP/2 cleartext/TLS server runtime path.
http3boolfalseHTTP/3 server runtime path.
http3RetryboolfalseRequire a Retry token before accepting the QUIC Initial, validating the peer address and limiting amplification.
http3RetryKey?[32]u8nullHMAC key for Retry tokens; a fresh random key per connection is used when null.
loggingLoggingOptions{}Event callback + level (silent by default).
enableDocsbooltrueMount /openapi.json, /docs, /redoc by default.
docs?docs.ConfignullOverrides for docs routes when enabled.
docsTitle[]const u8"HTTPX API"Title used by the docs UI.
watchboolfalseWatch a directory and broadcast changes.
watchDir[]const u8"."Directory to watch when watch is enabled.
liveReloadboolfalseEnable the SSE live-reload endpoint and script injection.
liveReloadPath[]const u8"/__httpx_liveReload"Mount path for the live-reload endpoint.
templates?TemplateConfignullTemplate engine config (auto-discovers templates/ when null).
tls?tls.Server.ConfignullTLS/HTTPS config (certificate + private key).

All server settings are passed inline as the final .{} argument to httpx.Server.init. Omitted fields use the built-in defaults; .{} is the safe default.

Operational Deployment Environment Configuration ​

Server configuration remains pure explicit Zig options via .{}. If an application wishes to bind ports or hosts from process environment variables or a .env file, it selectively uses httpx.env:

zig
// Read operational deployment values via httpx.env utils:
var server = try httpx.Server.init(allocator, io, .{
    .host = httpx.env.get("HOST") orelse "0.0.0.0",
    .port = httpx.env.getInt(u16, "PORT") orelse 8080,
    .workerCount = httpx.env.getInt(u16, "WORKERS") orelse 0,
});
defer server.deinit();

// Or read from a loaded .env file:
var envStore = try httpx.env.loadFile(allocator, ".env");
defer envStore.deinit();

var env_server = try httpx.Server.init(allocator, io, .{
    .host = envStore.get("HOST") orelse "0.0.0.0",
    .port = if (envStore.get("PORT")) |p| try std.fmt.parseInt(u16, p, 10) else 8080,
});
defer env_server.deinit();

Application routes, middleware composition, handler logic, and internal buffers remain explicit code options configured via httpx.Server.init.

Connection handling and concurrency ​

The accept loop submits connections to a fixed worker pool. The server tracks active and queued connections together and admits no more than maxConcurrentConnections; the worker queue independently holds at most maxQueuedConnectionTasks. A full queue or connection limit closes the newly accepted socket, so the effective queued capacity is bounded by both settings. maxConnections is a separate lifetime limit and counts successfully queued connections.

The timeout fields are absolute deadlines for each I/O phase, not inactivity windows that restart after every byte. A server-owned monitor interrupts the timed-out direction on accepted sockets so a blocked I/O operation releases its worker. A zero timeout disables the corresponding deadline. Graceful requestShutdown() stops acceptance, lets active requests finish within their I/O deadlines, and closes queued sockets without starting new requests. stop() interrupts active socket I/O immediately. A user handler that blocks without returning is not forcibly cancelled.

Route registration and configuration must finish before run() starts. Handlers and the logging callback may be invoked concurrently from different worker threads, so shared application state must be synchronized by the application.

Server Lifecycle ​

zig
server.run();              // blocking accept loop
const thread = try server.start(); // non-blocking, returns std.Thread
server.pause();            // pause accepting new connections
server.resumeAccepting();  // resume accepting new connections
server.requestShutdown();  // graceful shutdown (drains in-flight requests)
server.stop();             // immediate shutdown
const port = server.localPort(); // actual bound port (useful with .port = 0)

HTTP/2 and HTTP/3 Runtime Configuration ​

zig
    const io = std.Io.Threaded.global_single_threaded.io();
var server = try httpx.Server.init(allocator, io, .{
    .host = "127.0.0.1",
    .port = 8080,
    .http2 = true,
    .http3 = false,
});
defer server.deinit();

Port Conflict Handling ​

zig
    const io = std.Io.Threaded.global_single_threaded.io();
var server = try httpx.Server.init(allocator, io, .{
    .host = "127.0.0.1",
    .port = 8080,
    .portStrategy = .incremental,
    .maxPortAttempts = 32,
});
defer server.deinit();

server.run();
  • .strict: return an error immediately if bind fails.
  • .incremental: try port + 1, port + 2, ... until success or attempts are exhausted.
  • .exit: fail if the port is occupied.

Methods ​

run ​

Starts the server. This method blocks.

zig
server.run();

start ​

Starts the server in a background thread. Returns a thread handle that can be joined.

zig
const thread = try server.start();
// Server is now running in the background
// ...
server.stop(); // Stop when done
thread.join(); // Wait for the thread to finish

localPort ​

Returns the effective bound port (useful with .port = 0 or portStrategy = .incremental).

zig
const p = server.localPort();
_ = p;

stop ​

Stops the server immediately and closes active sockets.

zig
server.stop();

use ​

Adds a middleware to the global stack.

zig
try server.use(httpx.middleware.helmet);
try server.use(httpx.middleware.cors);

Logging ​

HTTPX never prints on its own. Configure the server event callback to observe requests (see Observability: Logging):

zig
fn onEvent(event: httpx.logging.ServerEvent) void {
    if (event.kind == .requestCompleted) {
        std.debug.print("{s} {s} {d}\n", .{ event.method, event.path, event.status });
    }
}

var server = try httpx.Server.init(allocator, io, .{
    .host = "127.0.0.1",
    .port = 8080,
    .logging = .{ .callback = onEvent },
});

Omit .logging.callback (the default) for fully silent operation.

Routing Methods ​

MethodDescription
get(path, handler)Register GET route
post(path, handler)Register POST route
put(path, handler)Register PUT route
delete(path, handler)Register DELETE route
patch(path, handler)Register PATCH route
head(path, handler)Register HEAD route
options(path, handler)Register OPTIONS route
use(mw)Register global middleware
static(mount, dir)Serve a filesystem directory
spa(mount, dir)Serve a directory with SPA fallback
metrics(path)Mount the Prometheus endpoint
setStatusHandler(code, handler)Custom handler per status code

Quick Example ​

zig
const std = @import("std");
const httpx = @import("httpx");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();
    const io = std.Io.Threaded.global_single_threaded.io();

    var server = try httpx.Server.init(allocator, io, .{});
    defer server.deinit();

    // Add middleware
    try server.use(httpx.middleware.helmet);
    try server.use(httpx.middleware.cors);

    // Register routes
    try server.get("/", homePage);
    try server.get("/api/users", listUsers);
    try server.post("/api/users", createUser);
    try server.get("/api/users/{id}", getUser);
    try server.put("/api/users/{id}", updateUser);
    try server.delete("/api/users/{id}", deleteUser);

    std.debug.print("Server listening on http://localhost:8080\n", .{});
    server.run();
}

fn homePage(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    return ctx.html("<h1>Welcome to httpx.zig!</h1>");
}

fn listUsers(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    return ctx.renderJson(.{ .users = &.{} });
}

Explicit Server Types ​

SSE Responses ​

Server-Sent Events use httpx.sse.Event.Writer over a normal handler response (see examples/sseServer.zig and SSE). The router buffers each response, so a handler emits a finite batch of events; long-lived SSE needs a custom HTTP/1.1 connection loop. There is no ctx.sse(...) helper; parse inbound streams with httpx.sse.Event.Parser (eventType, data, id, retryMs fields on parsed events).

Route Groups ​

Register versioned route families explicitly (no group helper object):

zig
try server.get("/api/v1/users", listUsers);
try server.post("/api/v1/users", createUser);
try server.get("/api/v1/users/{id}", getUser);

Path parameters use {name} segments and are read with ctx.param("id").

Custom 404 Handler ​

zig
const router_mod = httpx.web.router;
var router = router_mod.Router.init(allocator);
defer router.deinit();
router.setNotFoundHandler(notFound);

fn notFound(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    return ctx.textStatus(404, "Not Found");
}

Context ​

The Context struct is passed to every route handler and middleware.

Fields ​

FieldTypeDescription
allocatorAllocatorRequest-scoped allocator
path[]const u8Clean request path
methodMethodRequest method
headers[]HeaderRequest headers
body[]u8Request body bytes
isTlsboolWhether the connection uses TLS

Request Accessors ​

MethodDescription
param(name)Get URL path parameter ({id})
queryParam(name)Get query string parameter
header(name)Get request header value (case-insensitive)
cookie(name)Get request cookie value by name
bearerToken()Parse Authorization: Bearer <token>
basicAuth()Parse Authorization: Basic ... into username/password
remoteAddress()Peer address (honors X-Forwarded-* only from configured trusted proxies)
scheme()"https" for TLS, else "http"
host()Request host
json(T)Parse request body as typed JSON into T

Response Helpers ​

MethodDescription
html(content) / htmlStatus(code, content)HTML responses
text(content) / textStatus(code, content)Plain-text responses
renderJson(value) / renderJsonStatus(code, value)JSON responses
render(name, data) / renderStatus(code, name, data)Template responses
xml(content) / rss(content) / atom(content)Feed responses
robots(content) / sitemap(content)Crawler responses
binary(bytes, contentType)Binary responses
custom(code, contentType, content)Fully custom responses
redirect(location, code)Redirect responses
jsonFmt(fmt, args)Formatted JSON responses

Example Context Usage ​

zig
fn getUser(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    // Get URL parameter
    const id = ctx.param("id") orelse return ctx.textStatus(400, "Missing user ID");

    // Get query parameter
    const format = ctx.queryParam("format") orelse "json";
    _ = format;

    // Get request header
    const auth = ctx.header("Authorization");
    _ = auth;

    // Return JSON response
    return ctx.renderJson(.{
        .id = id,
        .name = "John Doe",
        .email = "john@example.com",
    });
}

Handlers ​

Handlers are functions that take a *Context and return a !Response.

zig
const Handler = *const fn (*Context) anyerror!Response;

fn myHandler(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    return ctx.renderJson(.{ .message = "Hello World" });
}

Handler Patterns ​

zig
// Simple text response
fn hello(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    return ctx.text("Hello, World!");
}

// JSON response with status
fn created(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    return ctx.renderJsonStatus(201, .{ .id = 1, .created = true });
}

// Redirect
fn redirectHome(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    return ctx.redirect("/", 302);
}

// Error handling
fn riskyHandler(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    const data = doSomethingRisky() catch |err| {
        return ctx.renderJsonStatus(500, .{
            .error = "Internal Server Error",
            .message = @errorName(err),
        });
    };
    return ctx.renderJson(data);
}

Static Files ​

Serve directories with server.static(mount, dir) (filesystem, with embedded-asset fallback) instead of hand-rolled file handlers:

zig
// Serves ./public/* under /static with ETag + conditional GET.
try server.static("/static", "./public");

For runnable demos see examples/staticFiles.zig, examples/staticSite.zig, and examples/staticEmbedded.zig (single-file embedded mode).

Error Handling ​

Handle route-level errors in handlers and return explicit status codes as needed:

zig
fn handler(ctx: *httpx.router.Context) anyerror!httpx.router.Response {
    const result = riskyOperation() catch |err| switch (err) {
        error.NotFound => return ctx.renderJsonStatus(404, .{ .error = "Not Found" }),
        error.Unauthorized => return ctx.renderJsonStatus(401, .{ .error = "Unauthorized" }),
        else => return ctx.renderJsonStatus(500, .{ .error = "Internal Server Error" }),
    };
    return ctx.renderJson(result);
}

See Also ​

Released under the MIT License.