A pure-Zig implementation of the QUIC transport protocol (RFC 9000 / 9001 / 9002) with full HTTP/3 and QPACK support.
| RFC | Title | Status |
|---|---|---|
| RFC 9000 | QUIC: A UDP-Based Multiplexed and Secure Transport | ✅ Complete |
| RFC 9001 | Using TLS to Secure QUIC | ✅ Complete |
| RFC 9002 | QUIC Loss Detection and Congestion Control | ✅ Complete |
| RFC 9114 | HTTP/3 | ✅ Complete |
| RFC 9204 | QPACK: Header Compression for HTTP/3 | ✅ Complete |
| RFC 9369 | QUIC Version 2 | ✅ Complete |
| Frame | Type | Status |
|---|---|---|
| PADDING / PING | 0x00–0x01 | ✅ |
| ACK / ACK-ECN | 0x02–0x03 | ✅ |
| RESET_STREAM | 0x04 | ✅ |
| STOP_SENDING | 0x05 | ✅ |
| CRYPTO | 0x06 | ✅ |
| NEW_TOKEN | 0x07 | ✅ |
| STREAM | 0x08–0x0f | ✅ |
| MAX_DATA | 0x10 | ✅ |
| MAX_STREAM_DATA | 0x11 | ✅ |
| MAX_STREAMS (bidi/uni) | 0x12–0x13 | ✅ |
| DATA_BLOCKED | 0x14 | ✅ |
| STREAM_DATA_BLOCKED | 0x15 | ✅ |
| STREAMS_BLOCKED | 0x16–0x17 | ✅ |
| NEW_CONNECTION_ID | 0x18 | ✅ |
| RETIRE_CONNECTION_ID | 0x19 | ✅ |
| PATH_CHALLENGE / PATH_RESPONSE | 0x1a–0x1b | ✅ |
| CONNECTION_CLOSE (transport/app) | 0x1c–0x1d | ✅ |
| HANDSHAKE_DONE | 0x1e | ✅ |
All 13/13 quic-interop-runner test cases pass:
| Test | Status |
|---|---|
handshake |
✅ |
transfer |
✅ |
retry |
✅ |
chacha20 |
✅ |
keyupdate |
✅ |
resumption |
✅ |
zerortt |
✅ |
http3 |
✅ |
connectionmigration |
✅ |
multiplexing |
✅ |
v2 |
✅ |
ecn |
✅ |
rebind-port |
✅ |
- Version negotiation: Incoming Version Negotiation packets are handled in
Connection.handleVersionNegotiation(client must see QUIC v1 in the list). Compatible upgrade to QUIC v2 is implemented in the transport I/O layer when the server’s Initial uses v2 (seeio.zigandconnection.zigtests). - Demo
Endpoint(src/transport/endpoint.zig) andServer(src/transport/io.zig):max_connections(8) andMAX_CONNECTIONS(16) are small fixed arrays so the structs stay stack-friendly for samples, tests, and interop. These are not protocol caps — production embedders useServer.initFromSocket+feedPacketwith their own heap-allocated connection map sized to their workload (see "Embedder guide" below). - Random bytes: Connection IDs, stateless reset tokens, and path challenge data use the OS-backed CSPRNG (
std.crypto.random), not time-seeded PRNGs. - Path MTU (RFC 9000 §14): DPLPMTUD probing is not implemented. You can set
max_udp_payloadonServerConfig/ClientConfig; the stack clamps it to [1200, 65527] bytes and sizes HTTP/0.9 and HTTP/3 STREAM chunks from that limit (seetransport/path_mtu.zig).
Loopback throughput benchmark on Apple Silicon (M-series Mac), comparing zquic against quiche (Cloudflare, Rust/BoringSSL) and ngtcp2 (C/quictls). All built with release optimizations; steady-state averages (runs 3–5), 5 runs per data point.
| Transfer | zquic | quiche | ngtcp2 | Notes |
|---|---|---|---|---|
| 1 MB | 351 Mbps | 256 Mbps | 229 Mbps | zquic leads: fast handshake + protocol efficiency |
| 10 MB | 1,127 Mbps | 1,153 Mbps | 1,359 Mbps | All three competitive at medium transfers |
| 50 MB | 1,538 Mbps | 1,800 Mbps | 2,371 Mbps | ngtcp2 leads with assembly AES-GCM |
| 100 MB | 2,008 Mbps | 1,842 Mbps | 3,011 Mbps | ngtcp2 leads; zquic edges out quiche |
Key takeaways:
- zquic leads at small transfers (+37% over quiche, +53% over ngtcp2 at 1 MB) thanks to cached AES key schedules, zero FFI overhead, and batched client receives.
- At medium-to-large transfers all three are competitive, with ngtcp2 pulling ahead due to quictls's hand-tuned assembly AES-GCM.
- zquic stays ahead of or even with quiche across all transfer sizes.
Reproduce with:
# Quick self-benchmark
zig build bench-e2e -Doptimize=ReleaseFast -- --size-mb 50
# Two-way comparative benchmark (requires Rust toolchain for quiche)
bash bench/local_compare.sh zquic quiche
# Three-way benchmark (also requires cmake + libev for ngtcp2)
SIZE_MB=100 RUNS=5 bash bench/local_compare.sh zquic quiche ngtcp2- Zig 0.15.x
zig build # build library + server/client binaries
zig build test # run all 141 unit tests
zig build examples # build the example programszig build examples
./zig-out/bin/echo_server # crypto primitives walkthrough
./zig-out/bin/parse_packet # parse a QUIC Initial packet header
./zig-out/bin/session_resumption # session tickets and 0-RTT key derivationconst zquic = @import("zquic");
const crypto_keys = zquic.crypto.keys;
const types = zquic.types;
const dcid = try types.ConnectionId.fromSlice(&[_]u8{
0x83, 0x94, 0xc8, 0xf0, 0x3e, 0x51, 0x57, 0x08,
});
const secrets = crypto_keys.InitialSecrets.derive(dcid.slice());
// secrets.client.key — AES-128-GCM write key
// secrets.client.iv — AEAD base IV
// secrets.client.hp — header protection keyconst varint = zquic.varint;
var buf: [8]u8 = undefined;
const encoded = try varint.encode(&buf, 15293); // → 2 bytes: 0x7b 0xbd
const decoded = try varint.decode(encoded);
// decoded.value == 15293const header_mod = zquic.packet.header;
const result = try header_mod.parseLong(raw_bytes);
// result.header.packet_type — .initial / .handshake / .zero_rtt / .retry
// result.header.dcid — ConnectionId
// result.header.version — u32
// result.consumed — bytes consumedconst aead_mod = zquic.crypto.aead;
var ciphertext: [plaintext.len + 16]u8 = undefined;
try aead_mod.encryptAes128Gcm(&ciphertext, plaintext, aad, key, nonce);
var recovered: [plaintext.len]u8 = undefined;
try aead_mod.decryptAes128Gcm(&recovered, &ciphertext, aad, key, nonce);const session = zquic.crypto.session;
var store = session.TicketStore{};
store.store(ticket);
if (store.get(now_ms)) |t| {
const keys = session.deriveEarlyKeys(t);
// keys.key / keys.iv / keys.hp — ready for 0-RTT AEAD
}const h3 = zquic.http3.frame;
var buf: [256]u8 = undefined;
const written = try h3.writeFrame(&buf, @intFromEnum(h3.FrameType.headers), encoded_header_block);
const result = try h3.parseFrame(buf[0..written]);
// result.frame.headers.data / result.frame.data / result.frame.settings …src/
varint.zig Variable-length integer codec (RFC 9000 §16)
types.zig ConnectionId, StreamId, TransportError, …
packet/
header.zig Long/Short header parse + serialize
number.zig Packet number encode/decode (RFC 9000 §A.3)
packet.zig Initial, Retry, Version Negotiation builders
retry.zig Retry integrity tag (RFC 9001 §5.8)
version_negotiation.zig Version Negotiation parse/build
crypto/
keys.zig HKDF-Expand-Label, Initial secret derivation, key update
aead.zig AES-128-GCM + ChaCha20-Poly1305, header protection
initial.zig Initial packet protect/unprotect helpers
quic_tls.zig QUIC-TLS adapter (nonblock ↔ CRYPTO frames)
session.zig Session tickets, PSK store, 0-RTT key derivation
key_update.zig Key update (RFC 9001 §6), KeyPhaseState
frames/
frame.zig Frame union + parseOne dispatcher
ack.zig ACK frame with ECN
crypto_frame.zig CRYPTO frame
stream.zig STREAM frame
transport.zig RESET_STREAM, STOP_SENDING, MAX_DATA, PATH_CHALLENGE, …
transport/
io.zig UDP event loop: server + client, HTTP/0.9 + HTTP/3 I/O
connection.zig Connection state machine + ACK manager
endpoint.zig UDP socket dispatch
stream_manager.zig Stream multiplexing + in-order receive buffer
flow_control.zig Connection + stream flow control
migration.zig Path validation, connection migration (RFC 9000 §9)
loss/
recovery.zig RTT estimation (SRTT/RTTVAR), PTO, packet-threshold loss detection
congestion.zig New Reno congestion control (cwnd, ssthresh, slow start / CA / recovery)
http09/
server.zig HTTP/0.9 request parser + path resolver
client.zig HTTP/0.9 request builder + download path helper
http3/
frame.zig HTTP/3 frame codec (RFC 9114 §7)
qpack.zig QPACK: static table, dynamic table (RFC 9204)
cmd/
server.zig QUIC server binary
client.zig QUIC client binary
vendor/tls/ ianic/tls.zig @ 34248f38c189 (Zig 0.15 compatible)
interop/
Dockerfile Self-contained local build
Dockerfile.prebuilt CI-optimised image from pre-built binaries
run_endpoint.sh quic-interop-runner entry point
examples/
echo_server.zig Crypto primitives walkthrough
parse_packet.zig Parse a QUIC Initial packet
session_resumption.zig Session tickets and 0-RTT
The transport.io server and client are oriented around the quic-interop-runner HTTP/0.9 and
HTTP/3 paths. The APIs below let other protocols reuse the same TLS 1.3 + QUIC stack (custom
ALPN, opaque stream bytes, external UDP loops).
ServerConfig.alpnandClientConfig.alpn— when set, that exact string is sent in the TLS handshake (single protocol). It takes precedence overhttp3/http09.serverTlsAlpn(&ServerConfig)andclientTlsAlpn(&ClientConfig)— effective ALPN for the handshake (including the HTTP defaults whenalpnis null).
When raw_application_streams is true on both ServerConfig and ClientConfig:
- Incoming STREAM frames are appended to per-stream
RawAppStreamSlotbuffers as opaque bytes (handleRawApplicationStreamServer/handleRawApplicationStreamClient). No HTTP/0.9 or HTTP/3 parsing is performed. - Data is merged using the same contiguous-offset rules as the HTTP/3 download path (duplicates and gaps are handled conservatively).
Use rawAppRecvBuffer / Client.rawAppRecvBuffer for a []const u8 view of accumulated data
(same backing store as the slot; consume or copy before the slot is reused). This path is for
embedders that drive their own framing on top of QUIC streams.
Server.feedPacket(buf, src)— dispatch one datagram withoutrecvfrom(e.g. shared UDP port).Server.processPendingWork()— PTO, flush pending HTTP/raw sends,flushSendBatch, reap connections (call after draining injected packets).Server.initFromSocket(allocator, config, sock, take_ownership)— use a pre-bound IPv4 UDP socket; whentake_ownershipis false,deinitdoes not close the fd.Client.feedPacket(buf)— inject one datagram.Client.processPendingWork(server_addr)— Initial/Finished retransmits when the embedder owns the poll loop.Client.initFromSocket— same ownership semantics as the server.
try rawAllocateNextLocalUniStream/try rawAllocateNextLocalBidiStreamonConnState— RFC 9000 §2.1 local stream IDs; returnserror.StreamLimitExceededif the peer’sMAX_STREAMS/ transport-parameter budget is exhausted (do not mix with HTTP streams on the same connection).Server.sendRawStreamData(server, conn, stream_id, offset, data, fin)andClient.sendRawStreamData(...)— send one STREAM frame on 1-RTT; the embedder tracks per-stream offsets.
Dependents import the package module zquic from build.zig.zon; it imports vendored tls as tls.
QUIC uses TLS 1.3 without the TLS record layer (RFC 9001). A thin adapter in
src/crypto/quic_tls.zig strips/adds the 5-byte TLS record header so raw
handshake bytes flow through QUIC CRYPTO frames. The vendored
ianic/tls.zig nonblock API is used.
See CHANGELOG.md for version history. Releases are published
automatically on v* tags via .github/workflows/release.yml.
MIT