Server¶
Native Zig HTTP server -- 2.1x faster than uvicorn (13k vs 6k req/s).
Architecture¶
The HyperDjango server is a compiled Zig native extension that handles HTTP parsing, routing, WebSocket upgrades, and response serialization entirely in native code. Key architectural components:
- 24-thread pool -- pre-spawned OS threads handle connections concurrently without the GIL
- Radix trie router -- O(log n) route matching with 528 ns per resolve for dynamic routes
- Zero-copy request parsing -- HTTP headers and body parsed without unnecessary allocations
- Native response builder -- responses serialized directly to socket buffers
- RFC 6455 WebSocket -- full WebSocket support with SIMD XOR unmasking
The server is designed for Python 3.14t (free-threaded) and runs request handlers without GIL contention.
Quick Start¶
from hyperdjango import HyperApp
app = HyperApp("myapp")
@app.get("/")
async def index(request):
return {"message": "Hello!"}
@app.post("/users")
async def create_user(request):
data = request.json
return {"created": data["name"]}, 201
app.run(host="0.0.0.0", port=8000)
Server Configuration¶
app.run() Parameters¶
app.run(
host="0.0.0.0", # Bind address (default: "127.0.0.1")
port=8000, # Bind port (default: 8000)
prod=False, # Production mode (disables debug pages, enables optimizations)
)
| Parameter | Default | Description |
|---|---|---|
host |
"127.0.0.1" |
Network interface to bind to. Use "0.0.0.0" for all interfaces. |
port |
8000 |
TCP port to listen on |
prod |
False |
Production mode -- disables debug error pages, enables security headers |
Production Mode¶
When prod=True:
- Detailed error pages are replaced with generic
{"detail": "Internal Server Error"} - Security headers are applied (HSTS, X-Frame-Options, etc.)
- Debug endpoints like
/debug/performanceare disabled - Static file serving defers to the reverse proxy
Routing¶
HTTP Methods¶
@app.get("/users")
async def list_users(request): ...
@app.post("/users")
async def create_user(request): ...
@app.put("/users/{id:int}")
async def update_user(request, id): ...
@app.patch("/users/{id:int}")
async def patch_user(request, id): ...
@app.delete("/users/{id:int}")
async def delete_user(request, id): ...
Path Parameters with Type Conversion¶
# Integer parameter -- auto-converted to int
@app.get("/users/{id:int}")
async def get_user(request, id): # id is already an int
...
# String parameter (default)
@app.get("/users/{username:str}")
async def get_by_name(request, username): ...
# Slug parameter (letters, numbers, hyphens)
@app.get("/articles/{slug:slug}")
async def get_article(request, slug): ...
# UUID parameter
@app.get("/items/{item_id:uuid}")
async def get_item(request, item_id): # item_id is a UUID string
...
# Path parameter (catches remaining path segments)
@app.get("/files/{path:path}")
async def serve_file(request, path): # path = "docs/guide/intro.md"
...
Supported parameter types:
| Type | Pattern | Example Match |
|---|---|---|
str |
[^/]+ |
hello |
int |
[0-9]+ |
42 |
slug |
[a-zA-Z0-9-]+ |
my-article |
uuid |
UUID v4 | 550e8400-e29b-41d4-a716-446655440000 |
path |
.+ |
docs/guide/intro.md |
Multiple Methods¶
@app.route("/items", methods=["GET", "POST"])
async def items(request):
if request.method == "GET":
return await list_items()
else:
return await create_item(request)
URL Namespaces & Includes¶
Organize routes with routers and namespaces:
from hyperdjango.router import Router
blog = Router()
blog.get("/", list_posts, name="list")
blog.get("/{id:int}", post_detail, name="detail")
blog.post("/{id:int}/comment", add_comment, name="comment")
api = Router()
api.get("/users", list_users, name="users")
api.get("/stats", get_stats, name="stats")
# Mount routers with namespaces
app.router.include("/blog", blog, namespace="blog")
app.router.include("/api/v1", api, namespace="api")
# Reverse URL resolution
app.router.reverse("blog:detail", id=42) # "/blog/42"
app.router.reverse("blog:list") # "/blog/"
app.router.reverse("api:users") # "/api/v1/users"
Radix Trie Router¶
The router uses a native Zig radix trie data structure for route matching. This provides O(log n) lookups regardless of the number of routes, with benchmark performance of 528 ns per resolve for dynamic routes with parameters.
Static routes (no parameters) are resolved even faster via exact hash map lookup.
Request Object¶
request.method # "GET", "POST", "PUT", "PATCH", "DELETE"
request.path # "/users/42"
request.query # {"page": "1", "sort": "name"}
request.json # Parsed JSON body (lazy)
request.form # Parsed form data (lazy)
request.headers # Dict of headers (case-insensitive keys)
request.cookies # Dict of cookies (native Zig parser)
request.client_ip # Client IP address (respects X-Forwarded-For)
request.is_secure # True if HTTPS (checks X-Forwarded-Proto)
request.text # Raw body as string
request.bytes # Raw body as bytes
The request object uses lazy parsing -- request.json, request.form, and request.cookies are only parsed when first accessed. Cookie parsing uses the native Zig cookie parser with percent-decoding, benchmarking faster than Python's http.cookies module.
Response Builder¶
from hyperdjango.response import Response
# JSON response (most common)
Response.json({"key": "value"})
Response.json(data, status=201)
# HTML response
Response.html("<h1>Hello</h1>")
Response.html(rendered_template, status=200)
# Plain text
Response.text("plain text")
Response.text("Not Found", status=404)
# Redirect
Response.redirect("/other") # 302 temporary
Response.redirect("/other", status=301) # 301 permanent
# File download
Response.file("/path/to/file.pdf")
Response.attachment("/path/to/file.pdf", filename="report.pdf")
# Streaming response
Response.stream(async_generator)
# Server-Sent Events
Response.sse(event_generator)
Response objects support headers, cookies, and caching:
response = Response.json({"ok": True})
response.headers["X-Custom"] = "value"
response.headers["Set-Cookie"] = "session=abc; HttpOnly; Secure"
The native response builder serializes headers and body directly to the socket buffer, avoiding intermediate string concatenation.
Middleware¶
from hyperdjango import RateLimitMiddleware
from hyperdjango.standalone_middleware import (
CORSMiddleware,
SecurityMiddleware,
TimingMiddleware,
LoggingMiddleware,
CompressionMiddleware,
CSRFMiddleware,
)
# CORS
app.use(
CORSMiddleware(
origins=["https://mysite.com"],
methods=["GET", "POST", "PUT", "DELETE"],
headers=["Authorization", "Content-Type"],
max_age=3600,
)
)
# Security headers (HSTS, X-Frame-Options, etc.)
app.use(SecurityMiddleware(hsts=True, frame_deny=True))
# Request timing (adds X-Response-Time header)
app.use(TimingMiddleware())
# Access logging
app.use(LoggingMiddleware())
# Rate limiting
app.use(RateLimitMiddleware(max_requests=100, window=60))
# Response compression (gzip)
app.use(CompressionMiddleware(min_size=1024))
# CSRF protection
app.use(CSRFMiddleware(secret="your-secret"))
Middleware executes in the order it is registered. Each middleware receives the request and a call_next function:
async def custom_middleware(request, call_next):
# Before handler
start = time.time()
response = await call_next(request)
# After handler
elapsed = time.time() - start
response.headers["X-Custom-Time"] = f"{elapsed:.3f}s"
return response
app.use(custom_middleware)
WebSocket Support¶
WebSocket connections are handled with full RFC 6455 compliance. The Zig server performs the HTTP upgrade handshake and SIMD XOR unmasking of frames natively, then hands the connection to your Python handler.
A handler is an async def registered with @app.websocket(path). It is
called once per connection; you drive the connection with the methods on
the ws object. The canonical echo handler:
from hyperdjango.websocket import WebSocketDisconnect
@app.websocket("/ws/echo")
async def echo(ws):
await ws.accept() # complete the handshake
async for message in ws.iter_text(): # iterate frames until the client leaves
await ws.send_text(message)
WebSocket API¶
The ws object exposes a consistent, correctly-typed API (identical on the
native and ASGI backends, so handlers are portable):
@app.websocket("/ws/chat")
async def chat(ws):
await ws.accept(subprotocol=None) # must be called before I/O
# --- send ---
await ws.send_text("hello") # text frame
await ws.send_bytes(b"\x00\x01") # binary frame
await ws.send_json({"type": "hi"}) # JSON-encoded text frame
# --- receive (each raises WebSocketDisconnect when the client goes away) ---
msg = await ws.receive() # type-preserving: str for a text frame,
# bytes for a binary frame (use for mixed protocols)
text = await ws.receive_text() # always str (a binary frame is UTF-8 decoded)
data = await ws.receive_bytes() # always bytes (a text frame is UTF-8 encoded)
obj = await ws.receive_json() # parsed JSON
# --- iterate (each stops cleanly on disconnect) ---
async for m in ws.iter_text():
... # yields str
async for b in ws.iter_bytes():
... # yields bytes
async for o in ws.iter_json():
... # yields parsed JSON
# --- close ---
await ws.close(code=1000, reason="Normal closure")
Pick the receive method by protocol: receive_text()/iter_text() for a
text protocol, receive_bytes()/iter_bytes() for a binary protocol, and
receive() when a single connection carries both frame types (it
reflects the frame type in the return type — the right choice for an echo or
proxy).
Connection metadata is available as attributes: ws.headers (dict),
ws.path, ws.query_string. Handle disconnects by catching
WebSocketDisconnect (the iter_* helpers swallow it and stop cleanly):
from hyperdjango.websocket import WebSocketDisconnect
@app.websocket("/ws/chat")
async def chat(ws):
await ws.accept()
try:
while True:
msg = await ws.receive_json()
await handle(msg)
except WebSocketDisconnect:
pass # client closed — run any cleanup here
Concurrency model — and how to write handlers that scale¶
Each connection's handler runs on an asyncio event loop, and the receive
path is fully non-blocking (native MSG_DONTWAIT reads driven by the loop's
selector — no thread parked per message). There are two models for how
loops map to connections, selected by the WEBSOCKET_CONCURRENCY setting:
-
shared(the default) — shared event-loop pool. A small fixed set of event loops (one per core) each multiplex many connections. The concurrent-connection ceiling is bounded by fds/memory (not the thread pool), throughput uses every core, and memory stays ~flat as connections grow. This is the right default for real WebSocket workloads (chat, notifications, live dashboards — many mostly-idle clients) and still measures 1.6–2.3× faster on throughput than the single-loopwebsocketslibrary while holding far more connections (see Benchmarks). -
thread(opt-out) — one OS thread per connection. SetWEBSOCKET_CONCURRENCY=thread(orHYPER_WEBSOCKET_CONCURRENCY=thread). Max live connections equals the thread pool size. Only needed for handlers that do heavy synchronous per-message CPU work and cannot be made cooperative — most handlers should use the default.
Cooperative-handler contract (required — the default depends on it): a
handler may await network I/O and CPU-light work freely, but must not
park a thread per connection — e.g.
await loop.run_in_executor(None, blocking_queue.get). On a shared loop,
many such handlers exhaust the loop's default executor and stall. When you
need to feed a handler from callbacks that fire on other threads
(channel/pub-sub delivery), use a cooperative bridge: an asyncio.Queue fed
via loop.call_soon_threadsafe, with the writer as a task. (The framework's
own websocket_channel_handler and the Room/Channel helpers already do
this for you — you only need the pattern below when hand-rolling a bridge.)
import asyncio, contextlib
@app.websocket("/ws/room")
async def room(ws):
await ws.accept()
loop = asyncio.get_running_loop()
outgoing: asyncio.Queue = asyncio.Queue(maxsize=1000)
def on_event(msg): # called from ANY thread
with contextlib.suppress(RuntimeError): # loop closing/closed
loop.call_soon_threadsafe(outgoing.put_nowait, msg)
sub_id = channel.subscribe(on_event) # your pub/sub source
async def writer():
while True:
await ws.send_json(await outgoing.get())
w = asyncio.create_task(writer())
try:
async for incoming in ws.iter_json():
await channel.publish(incoming) # read side
except WebSocketDisconnect:
pass
finally:
w.cancel()
with contextlib.suppress(asyncio.CancelledError):
await w
channel.unsubscribe(sub_id)
This pattern works identically under both models. The examples/websocket_chat
app uses exactly this shape.
Tuning¶
These are HyperDjango settings — set them in Django settings
(HYPERDJANGO_<NAME>), via HYPER_<NAME> environment variables, or in a
.env file. Defaults are chosen for good production behavior out of the box.
| Setting | Default | Effect |
|---|---|---|
WEBSOCKET_CONCURRENCY |
shared |
shared = multiplex connections over an event-loop pool (no connection ceiling, the recommended default). thread = one OS thread per connection (max = THREAD_POOL_SIZE). |
WEBSOCKET_LOOP_COUNT |
0 (auto) |
Event loops in the shared pool. 0 = min(cpu_count, 8). More loops = more parallelism up to core count. |
THREAD_POOL_SIZE |
24 | HTTP worker threads. In thread WebSocket mode, also the max concurrent WebSocket connections. |
HYPER_THREAD_STACK_SIZE |
16 MiB | Per-worker-thread stack (env-only). Lower it (bytes) for many-connection, shallow-call-depth workloads to cut memory; keep the default if handlers have deep call chains. |
SIMD XOR Unmasking¶
WebSocket frames from clients are masked per RFC 6455. The Zig implementation uses SIMD operations to XOR-unmask 16 bytes at a time, significantly faster than byte-by-byte unmasking. Server→client frames are written with a single writev syscall (header + payload) and TCP_NODELAY is set on every connection to minimize per-message latency.
Request Validation Pipeline¶
Incoming requests pass through a validation pipeline before reaching the route handler:
- Connection accept -- TCP connection accepted by the thread pool
- HTTP parsing -- request line, headers, and body parsed in Zig
- Size validation -- body size checked against
max_body_size(default 10 MB) - Routing -- radix trie lookup to find matching handler
- Parameter extraction -- path parameters extracted and type-converted
- Middleware chain -- request passes through middleware stack
- Handler execution -- route handler called with validated request
If any step fails, an appropriate HTTP error response is returned immediately without invoking downstream handlers.
Connection Keep-Alive¶
The server supports HTTP/1.1 keep-alive connections by default. Multiple requests can be sent over the same TCP connection, reducing connection establishment overhead.
Keep-alive behavior:
- Connections are kept alive unless the client sends
Connection: close - Idle connections are reaped after a configurable timeout
- The server sends
Connection: keep-alivein responses
Socket Tuning¶
The native server exposes a few low-level socket knobs via environment variables (independent of the HYPER_HTTP_SERVER_MODEL threaded/reactor choice — both modes honor them):
| Variable | Default | Effect |
|---|---|---|
HYPER_TCP_NODELAY |
1 (on) |
Sets TCP_NODELAY on every accepted connection, disabling Nagle's algorithm. Without it, Nagle interacting with the peer's delayed ACK can add up to ~40 ms of latency to small keep-alive responses. Set HYPER_TCP_NODELAY=0 to restore Nagle. |
HYPER_LISTEN_BACKLOG |
1024 |
Depth of the kernel accept queue (listen() backlog). The kernel clamps this to the system somaxconn limit — raise kern.ipc.somaxconn (macOS) / net.core.somaxconn (Linux) via sysctl if you need a deeper queue under connection storms. The previous hardcoded value of 128 equalled macOS's default somaxconn, so bursts could overflow the queue and drop SYNs before load-shedding (HYPER_HTTP_MAX_PENDING) could fire. |
The accept loop is edge-drained: each poll() wakeup accepts every pending connection until the queue is empty, so a burst of simultaneous connects is admitted in one pass rather than one-per-wakeup.
Static File Serving¶
In development, the server can serve static files directly:
app = HyperApp("myapp", static="static/")
# Files in static/ are served at /static/
# /static/css/style.css -> static/css/style.css
For production, use a reverse proxy (nginx) for static files. The StaticFilesMiddleware handles ETag generation, Cache-Control headers, gzip compression, and If-None-Match/If-Modified-Since conditional requests.
See the Static Files documentation for details on collectstatic, manifest hashing, and CDN integration.
Hot Reload¶
In development, the server watches for file changes and automatically reloads:
from hyperdjango.hot_reload import HotReloader
reloader = HotReloader(
watch_dirs=["myapp/"],
extensions=[".py", ".html"],
)
reloader.start()
The file watcher uses native OS APIs:
- macOS: kqueue (no polling)
- Linux: inotify (no polling)
When a change is detected, affected modules are reloaded with importlib.reload() and an SSE event is pushed to connected browsers for instant page refresh.
Django WSGI Bridge¶
Run Django applications through the Zig HTTP server for improved performance:
The ZigHandler bridges the Zig HTTP server to Django's WSGI interface:
- Zig server accepts the connection and parses the HTTP request
- The request is converted to a WSGI environ dict
- Django's WSGI handler processes the request
- The Django response is sent back through the Zig server using
sendFullResponse
The bridge supports:
- Full response headers (Set-Cookie, CSRF tokens, etc.)
- Streaming responses
- File responses
- WebSocket upgrade (passed through to ASGI)
Performance¶
The WSGI bridge provides the Zig server's connection handling and parsing performance while running standard Django views. This typically yields 1.5-2x throughput improvement over gunicorn/uvicorn for Django applications due to faster HTTP parsing and connection management.
Graceful Shutdown¶
The Zig server handles shutdown signals natively using a self-pipe + atomic flag architecture. No Python signal handlers needed -- the shutdown is coordinated at the C/Zig level for reliability.
Signal Handling¶
- SIGTERM -- graceful shutdown (systemd, Docker, process managers)
- SIGINT -- graceful shutdown (Ctrl+C in terminal)
Both signals write to a self-pipe that wakes the accept loop immediately (async-signal-safe). The accept loop exits, then the server drains in-flight requests.
Shutdown Sequence¶
- Signal handler sets atomic
shutdown_flagand writes to self-pipe - Accept loop's
poll()wakes and breaks (stops accepting new connections) - Worker threads finish their current request (tracked via
active_requestscounter) - Drain timeout: 30 seconds for in-flight requests to complete
- Worker threads exit (queue returns null on shutdown, condition variable broadcast)
on_shutdownhooks called (Python-side cleanup)- PID file removed
- Clean exit (exit code 0)
Programmatic Shutdown¶
Trigger shutdown from Python code (useful for tests, health checks, or admin endpoints):
from hyperdjango._hyperdjango_native import _server_shutdown
_server_shutdown() # Sets the shutdown flag, server exits after draining
Server Management¶
CLI Commands¶
# Development (foreground)
hyper run --app app:app --port 8000
# Production (background daemon)
hyper start --app app:app --port 8000 --prod
hyper stop --port 8000 # SIGTERM graceful shutdown
hyper restart --app app:app --port 8000 # stop + start
hyper status --port 8000 # Check if running
hyper start writes a PID file (.hyper.<port>.pid) and redirects output to .hyper.<port>.log. hyper stop sends SIGTERM and waits up to 30 seconds for graceful exit, then SIGKILL as fallback.
systemd Integration¶
Generate and install a production systemd unit file:
# Generate unit file (writes to current directory if not root)
hyper systemd install --app app:app --port 8000
# Install and enable as root
sudo hyper systemd install --app app:app --port 8000 --enable
# Manage the service
sudo systemctl status hyperdjango-myapp
sudo systemctl restart hyperdjango-myapp
sudo journalctl -u hyperdjango-myapp -f
# Remove
sudo hyper systemd uninstall
The generated unit file includes:
Type=execwithKillSignal=SIGTERMfor graceful shutdownTimeoutStopSec=30matching the drain timeoutRestart=on-failurewith 5-second delay- Security hardening:
PrivateTmp,ProtectSystem=strict,NoNewPrivileges - Resource limits:
LimitNOFILE=65536,LimitNPROC=4096 KillMode=mixedto clean up worker threads
Docker¶
FROM python:3.14-slim
WORKDIR /app
COPY . .
RUN pip install -e .
RUN hyper-build --release
EXPOSE 8000
CMD ["hyper", "run", "--app", "app:app", "--host", "0.0.0.0", "--port", "8000", "--prod"]
The server handles SIGTERM from Docker's stop command, draining connections before exit.
ASGI Compatibility¶
HyperDjango applications can be served by any ASGI server (uvicorn, hypercorn, daphne) for environments where the native Zig server is not available:
However, the native Zig server is strongly recommended for production -- it provides 2.1x higher throughput and lower latency.
Performance Benchmarks¶
| Metric | Zig Server | uvicorn |
|---|---|---|
| Requests/sec (hello world) | 13,000 | 6,000 |
| Route resolution (dynamic) | 528 ns | N/A |
| HTTP parse overhead | ~2 us | ~10 us |
| WebSocket frame unmask | SIMD (16 bytes/cycle) | byte-by-byte |
| Multipart boundary scan | 20.4 GB/s (SIMD) | ~100 MB/s |
The benchmarks were measured with wrk on a single-machine setup. Real-world performance depends on application logic, database queries, and network conditions.