A modern successor to Zap
Familiar to write.
Explicit by design.
Meet Baz — Bounded Async Zap. A pure Zig web framework on bounded/http. Typed applications, streaming responses, and memory boundaries you can reason about.
Its foundation, bounded/http, takes inspiration from TigerStyle: set explicit limits, reserve resources up front, and check ownership invariants. Connections, buffers, queues, and workers have fixed capacities; request sizes, response sizes, and I/O deadlines have enforced limits. When capacity runs out, the engine applies backpressure or rejects work instead of growing without bounds. These guarantees cover the framework’s resources; application code still owns its memory use and must cooperate with cancellation.
- Linux
- io_uring
- macOS
- kqueue
- Windows x64
- IOCP
Native HTTP I/O powered by bounded/http.
App state and endpoint methods.
Ordinary Zig composition.
Borrowed bytes in.
Decoding when you ask.
Framework capacity at startup.
Output reserved before dispatch.
01 / The idea
The good parts of Zap.
A fresh foundation.
Zap made building Zig web applications approachable. Baz carries forward its App and endpoint ergonomics, with a new API and a separate, bounded HTTP engine underneath.
The framework and its HTTP engine are written in Zig. Baz replaces Zap’s facil.io C foundation with bounded/http.
Native Windows support, too. Baz runs on Linux, macOS, and Windows x64—an addition to Zap’s platform support. Read the native evidence →
Your types, your App.
Use App(Shared), ordinary structs, and one router. Each App owns its lifecycle; your shared state remains explicit.
Keep the original bytes.
001 is text. So is false. Duplicate fields keep their order, and files[] is simply a name.
Know what stays alive.
Read borrowed input during the callback. Decode into your own buffer. Build a private response in capacity reserved before your handler runs.
Baz is a new framework, with its own package and API. The migration guide maps familiar Zap concepts to their Baz equivalents.
02 / An ordinary Zig App
A struct. A method.
A clear place for everything.
Define your shared state, choose App(Shared), and register an endpoint. The context gives you the request, your state, route captures, and a bounded response draft.
const Hello = struct {
pub fn get(_: *Hello, ctx: *Context) !void {
const query = try ctx.request.query();
if (query.firstRaw("name")) |param| {
var decoded: [1024]u8 = undefined;
const name = try web.params.percentDecodeInto(param.value_raw, &decoded);
return ctx.response.text(200, name); // copies before stack storage ends
}
return ctx.response.text(200, ctx.shared.greeting);
}
};This excerpt uses Context = web.App(Shared).Context. Register it with try app.endpoint("/hello", &hello). See the complete App example for initialization, stable state, and shutdown.
Start with explicit state.
Shared state and endpoint instances stay at stable addresses through App destruction. Register routes before startup. Synchronize mutable state used by concurrent callbacks.
Return a finished response.
Text, bytes, copied headers, JSON, and standard formatting all use bounded output. A failed handler discards its private draft before error mapping.
OWNERSHIPThis name is temporary, so response.text copies it. For retained input and immutable startup assets, borrowBody borrows the payload independently of staging capacity, within the total response limit. See the copy paths and limits → See a complete file-response handler →
Send the first bytes. Keep working.
Write a little. Flush it to the client. Pause, then send the next update—all inside the same handler.
fn progress(ctx: *Application.Context) !void {
try ctx.response.header("Cache-Control", "no-cache");
var stream = try ctx.response.stream(200, "text/plain; charset=utf-8", .{});
const out = stream.writer();
try out.writeAll("Starting…\n");
try out.flush();
try ctx.sleep(.fromMilliseconds(500));
try out.print("Completed step {d} of {d}\n", .{ 1, 2 });
try out.flush();
try ctx.sleep(.fromMilliseconds(500));
try out.writeAll("Done.\n");
try stream.finish();
}STREAMINGout is a standard std.Io.Writer. Each flush() sends the current bytes before the handler continues.
Updates as they happen.
Progress reports, generated text, and incremental output can reach clients before the handler returns. Use curl -N to watch the example arrive in three parts.
Bounded while you wait.
A stream uses one existing application worker and fixed output storage. A slow reader applies backpressure; cancellation releases the waiting handler safely. The HTTP I/O loop keeps running.
The complete example selects fixed workers. Headers are final after the first flush, and response size and request time remain bounded. Read the streaming guide → Native streaming evidence →
Many waiting streams. A small worker pool.
For higher concurrency, typed continuations return flush, wait, or finish. Waiting releases the executor while bounded typed state stays alive. Middleware runs once; completion, timeout, and disconnect clean up that state. Works inline or with fixed workers. Read the continuation guide →
Current limit: stream writes copy bytes. You cannot insert a borrowBody() call between flushes. See the response combination rules →
Send a file. Skip the body copy.
Already have a large image or file in memory? borrowBody() lets Baz send those existing bytes directly, avoiding a copy into its response buffer. A 5 MiB file stays in its original storage instead of being copied into another 5 MiB framework buffer.
This complete handler shows the API with an embedded HTML file: GET / returns the file’s contents as the entire response body. Use the same call with your image bytes and content type. Small bodies below the default 256-byte threshold—including this tiny HTML file—may still be copied.
const Shared = struct {};
const Application = web.App(Shared);
fn index(ctx: *Application.Context) !void {
return ctx.response.borrowBody(200, "text/html; charset=utf-8", @embedFile("assets/serve_index.html"));
}Here web = @import("baz"). The complete example registers try app.route("GET", "/", index). @embedFile includes the file at compile time, so this handler performs no filesystem I/O. Run it with zig build run-serve -Doptimize=ReleaseSafe -- --port 8080.
FEWER COPIESresponse.bytes() copies your body into bounded staging. borrowBody() avoids that payload copy for large retained bodies; a large file can use a small output arena. Headers and normal kernel transfers still have copying costs. See exactly where copies happen →
borrowBody() prepares one complete body. When this handler returns successfully, App publishes status 200, HTTP headers, and the file bytes. There is no body prefix or suffix. The embedded bytes stay alive for transmission.
Headers and handler work still fit.
Authenticate, choose an asset, and add response headers in your handler. Headers can be added before or after borrowBody() while the response is unpublished. A large immutable file can use a small output arena; the total response limit still applies.
Streaming + borrowing: not yet.
Writing and flushing a prefix, calling borrowBody() with an image, then continuing the stream is currently unsupported. Borrowing the whole body and starting a stream are separate choices—even before the first flush. Use the stream writer’s writeAll(image) to put image bytes between other streamed chunks; that path copies through bounded staging.
Verified on native Linux, macOS, and Windows: the separate 5 MiB fixture sends the original payload with a 4 KiB arena and no framework payload copy. Read the copy-counter and ownership evidence → Read what can be combined →
From a background job to a live page.
The complete jobs example puts Mustache, cookie sessions and SSE together. A startup producer publishes progress; bounded notifications wake waiting streams while their workers remain available. Event IDs support explicit, finite replay, and each resumed callback checks the session again. Run the application → See the job studio → Read the SSE and notification API →
03 / Data stays data
No guessing what
your parameters mean.
Query parameters and URL-encoded forms are ordered lists of raw text slices. Decode when needed, into a buffer you supply. Repeated fields remain repeated fields.
+ is treated.| Input | Raw value | Explicit operation | Result |
|---|---|---|---|
id=001 | 001 | Read the slice | Text, including leading zeros |
enabled=false | false | Read the slice | Text; no boolean conversion |
q=a+b%2Bc | a+b%2Bc | percentDecodeInto | a+b+c |
q=a+b%2Bc | a+b%2Bc | formDecodeInto | a b+c |
One multipart iterator.
Fields and files share the same flat representation: a raw name, optional filename and content type, and borrowed bytes. One file or several files use the same loop. Baz never saves an upload implicitly.
Read the upload example →Copies have a place.
Multipart parsing validates the complete supported body before iteration. If HTTP chunks split a body, request a bounded copy into caller storage before parsing a contiguous form. Application access to borrowed views ends with the callback.
Read the ownership contract →04 / Built on bounded/http
Application ergonomics above.
HTTP ownership underneath.
Baz imports bounded_http as a pinned external dependency. The engine stays independently usable. Baz supplies the application layer: routing, request views, endpoint composition, and responses.
Use Zig’s std.Io today.
Pass your caller’s std.Io at initialization. Standard memory readers and writers work inline. Finite blocking services require explicit fixed workers; the provider’s resource use remains the application’s responsibility.
Keep the engine’s transports.
Baz supports Linux with io_uring, macOS with kqueue, and native Windows x64 with IOCP. HTTP transport uses the engine directly. A Baz-owned std.Io implementation is a later design step.
05 / Get started
A working App in one command.
Use exact Zig 0.16.0 and Python 3. Clone Baz, then start the demo. The pinned HTTP engine is fetched automatically.
git clone https://github.com/technologylab-ai/baz.git
cd baz
zig build run-app -Doptimize=ReleaseSafe -- --port 8080curl 'http://127.0.0.1:8080/hello?name=Hello%20Zig'
curl --data 'value=x+y%2Bz' http://127.0.0.1:8080/form
curl -F 'files[]=@.zig-version' http://127.0.0.1:8080/uploadThe current executable serves IPv4 loopback over plain HTTP/1.1. Stop it with Ctrl-C. On Windows, use curl.exe; executables have an .exe suffix. For embedding Baz in your own project, follow the package guide and independent consumer fixture.
06 / Explore the examples
Examples you can run.
Twenty Zap ports and a new streaming example, using Baz’s public API. Every card opens maintained source in the reader. Pick a small example, then follow its types.
Build real pages with Mustache templates: a greeting form, typed user cards, and reusable partials. Parse once at startup and render straight into reserved HTML storage. The complete Zig example is just 52 lines → See the rendered preview →
24 of 24 examples
A minimal HTML response and an explicit route.
hello2Inspect methods, raw queries, headers, and bounded bodies.
hello_jsonJSON user lookup, with explicit ID parsing.
simple_routerFunctions, stateful routes, and a synchronized counter.
routesStatic and dynamic responses in one router.
serveServe immutable embedded assets through explicit routes.
sendfileFile content as an embedded asset; no sendfile syscall.
senderrorControlled errors, with no client-visible stack trace.
acceptExplicit, bounded content negotiation.
mustacheA greeting form and user cards: startup templates, typed data, bounded HTML.
continuationsMany waiting streams, a small worker pool: typed flush, wait, and finish callbacks.
streamingWrite, flush, sleep, and write again through a standard Zig writer.
jobsA complete application: Mustache, cookie sessions and live job progress over bounded SSE.
app_basicTyped Shared, endpoint state, and instance shutdown.
app_errorsError mapping and discarded private response drafts.
endpointBounded user CRUD on explicit application workers.
app_authPublic authentication middleware and typed request locals.
endpoint_authStateful endpoints with an explicit authentication check.
middlewarePublic ordered before/after/cleanup hooks and typed request locals.
middleware_with_endpointEndpoint composition with an early-stop path.
userpass_session32 reusable sessions, fixed server expiry, and logout across devices.
cookiesBorrowed cookie input, explicit expiry, and validated Set-Cookie output.
http_paramsRaw duplicates and explicit query versus form decoding.
bindataformpostOne flat loop for fields and files, with bounded previews.
zig build examples -Doptimize=ReleaseSafe
zig build run-http_params -Doptimize=ReleaseSafe -- --port 8080Cookies and redirects are public APIs, with explicit expiry and a local login/logout example. Middleware, authentication policy and session storage remain explicit application code. Read the complete catalog and its deliberate adaptations →
07 / Measured results
A first comparison.
The conditions included.
A basic same-host loopback comparison with Zap, measured on 6 September 2026. These results describe the initial Baz prototype c152e59, before package extraction.
| Host | Conn. / threads | Baz req/s | Zap req/s | Baz / Zap |
|---|---|---|---|---|
| macOS · M3 Max | 32 / 2 | 254,261 | 245,054 | 1.038× |
| Linux · Core Ultra 7 | 32 / 2 | 363,594 | 205,310 | 1.771× |
| macOS · M3 Max | 1 / 1 | 33,199 | 52,809 | 0.629× |
| Linux · Core Ultra 7 | 1 / 1 | 74,386 | 72,575 | 1.025× |
macOS: Apple M3 Max. Linux: Intel Core Ultra 7 258V. Three paired trials per profile; one-second warmup and three-second measurements. ReleaseSafe with Zig assertions enabled, one server thread, 128 connection slots, a 13-byte response, keep-alive, no pipelining, and no CPU affinity.
The Mac’s one-connection result is lower than Zap’s. These short trials establish neither production capacity nor latency guarantees, and do not isolate Baz’s API overhead. The hosts used different wrk revisions; Zap retained its inherited C optimization and sanitizer settings.
All 24 measured trials completed with zero wrk-reported socket or non-2xx/3xx errors. Exact responses were checked before and after timing, rather than for every timed response. The report preserves ranges, versions, commands, raw results, and cleanup evidence.
Read the experiment and raw evidence08 / What comes next
A useful first slice.
A visible roadmap.
Baz supports native Windows x64, Linux, and macOS. All three have passed Debug/ReleaseSafe verification, package checks, all 20 ported-example groups, and all 14 streaming groups. Windows also passed shard-handoff and console-shutdown cases. Baz remains experimental; these are correctness gates, not production qualification.
Implemented
The streaming App.
- Typed App, routes, and endpoint methods
- Raw query and form views; explicit codecs
- Flat multipart parsing
- Reserved response drafts and JSON
- Cookies, explicit expiry, and redirects
- Mustache templates with bounded rendering
- Streaming with a standard Zig writer
- Typed continuations: many streams, few workers
- Public middleware and typed request locals
- Reusable sessions with server expiry and revocation
- SSE encoding and bounded producer notifications
- A complete live-job application
- Caller std.Io and fixed worker services
- Native Windows x64, Linux, and macOS
Next API work
More ways to compose.
- Further response-copy reduction with explicit stream borrows
- Broader asynchronous service integration
Deferred / out of scope
Keep the scope honest.
- WebSockets: needs an upgrade lifecycle
- An owned std.Io provider and prototype: deferred
- TLS: out of scope
The framework reserves its own storage at startup. Application code and the caller’s I/O provider have separate resource responsibilities. Fixed workers do not provide arbitrary application isolation.
Follow the multi-session roadmap →09 / Read further
From first route
to the ownership details.
Guides stay as Markdown in the repository. The browser reader adds a table of contents, highlighted source, raw-file access, and print support.