Skip to content

HTTP/3 Server

QuicD includes built-in HTTP/3 support via the quicd-h3 crate. This guide shows you how to run an HTTP/3 server and implement custom request handlers.

HTTP/3 is the latest version of HTTP, built on QUIC instead of TCP. Key benefits:

  • Faster connection setup: 0-RTT connection resumption
  • No head-of-line blocking: Independent stream processing
  • Better loss recovery: Stream-level retransmission
  • Connection migration: Survives network changes (Wi-Fi → cellular)

QuicD implements HTTP/3 (RFC 9114) with QPACK header compression (RFC 9204).


Create config.toml:

host = "0.0.0.0"
port = 8443
log_level = "info"
[netio]
workers = 4
[quic]
max_connections_per_worker = 10000
Terminal window
sudo cargo run --release

QuicD automatically registers HTTP/3 for ALPN "h3" and "h3-29".

Expected output:

[INFO] Application registry initialized: alpns=["h3", "h3-29"]
[INFO] Network IO layer started: addr=0.0.0.0:8443, workers=4
Terminal window
cargo run --example h3_client

Client output:

✓ Handshake completed!
ALPN: h3
✓ Sent 23 bytes on stream 0
✓ Received echo: "Hello from test client!"

Congratulations! You’re running HTTP/3.


QuicD ships with DefaultH3Handler, a simple echo handler for testing:

// Built into quicd-h3
pub struct DefaultH3Handler;
#[async_trait]
impl H3Handler for DefaultH3Handler {
async fn handle_request(
&self,
request: H3Request,
response: H3ResponseSender,
) -> Result<(), H3Error> {
// Echo request info back as response body
let body = format!("{} {}", request.method, request.path);
response.send_response(200, vec![], Some(Bytes::from(body))).await
}
}

This is registered automatically in main.rs:

let registry = AppRegistry::new()
.register("h3", Arc::new(H3Factory::new(DefaultH3Handler)))
.register("h3-29", Arc::new(H3Factory::new(DefaultH3Handler)));

Implement H3Handler to build your own HTTP/3 application.

#[async_trait]
pub trait H3Handler: Send + Sync + 'static {
async fn handle_request(
&self,
request: H3Request,
response: H3ResponseSender,
) -> Result<(), H3Error>;
}
pub struct H3Request {
pub method: String, // "GET", "POST", etc.
pub scheme: String, // "https"
pub authority: String, // "example.com:8443"
pub path: String, // "/index.html"
pub headers: Vec<(String, String)>,
pub body: RecvStream, // Zero-copy body stream
}
impl H3ResponseSender {
pub async fn send_response(
&self,
status: u16,
headers: Vec<(String, String)>,
body: Option<Bytes>,
) -> Result<(), H3Error>;
pub async fn send_response_streaming(
&self,
status: u16,
headers: Vec<(String, String)>,
) -> Result<SendStream, H3Error>;
}

use async_trait::async_trait;
use bytes::Bytes;
use quicd_h3::{H3Error, H3Handler, H3Request, H3ResponseSender};
use std::path::PathBuf;
use tokio::fs;
pub struct FileHandler {
root: PathBuf,
}
impl FileHandler {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
}
#[async_trait]
impl H3Handler for FileHandler {
async fn handle_request(
&self,
request: H3Request,
response: H3ResponseSender,
) -> Result<(), H3Error> {
// Only handle GET
if request.method != "GET" {
return response.send_response(
405,
vec![("allow".into(), "GET".into())],
Some(Bytes::from("Method Not Allowed")),
).await;
}
// Construct file path (basic, no security checks!)
let file_path = self.root.join(request.path.trim_start_matches('/'));
// Read file
match fs::read(&file_path).await {
Ok(contents) => {
// Detect content type
let content_type = match file_path.extension().and_then(|s| s.to_str()) {
Some("html") => "text/html",
Some("css") => "text/css",
Some("js") => "application/javascript",
Some("json") => "application/json",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
_ => "application/octet-stream",
};
response.send_response(
200,
vec![
("content-type".into(), content_type.into()),
("content-length".into(), contents.len().to_string()),
],
Some(Bytes::from(contents)),
).await
}
Err(_) => {
response.send_response(
404,
vec![],
Some(Bytes::from("Not Found")),
).await
}
}
}
}

Modify quicd/src/main.rs:

// Add import
use your_crate::FileHandler;
// In main()
let app_registry = apps::AppRegistry::new()
.register("h3", Arc::new(quicd_h3::H3Factory::new(
FileHandler::new("./www") // Serve from ./www directory
)))
.register("h3-29", Arc::new(quicd_h3::H3Factory::new(
FileHandler::new("./www")
)));

use async_trait::async_trait;
use bytes::Bytes;
use quicd_h3::{H3Error, H3Handler, H3Request, H3ResponseSender};
use serde::{Deserialize, Serialize};
use serde_json;
#[derive(Deserialize)]
struct CreateUserRequest {
username: String,
email: String,
}
#[derive(Serialize)]
struct CreateUserResponse {
id: u64,
username: String,
email: String,
}
pub struct ApiHandler;
#[async_trait]
impl H3Handler for ApiHandler {
async fn handle_request(
&self,
mut request: H3Request,
response: H3ResponseSender,
) -> Result<(), H3Error> {
match (request.method.as_str(), request.path.as_str()) {
("POST", "/api/users") => {
// Read request body
let mut body_bytes = Vec::new();
while let Ok(Some(chunk)) = request.body.read().await {
match chunk {
quicd_x::StreamData::Data(data) => body_bytes.extend_from_slice(&data),
quicd_x::StreamData::Fin => break,
}
}
// Parse JSON
let create_req: CreateUserRequest = serde_json::from_slice(&body_bytes)
.map_err(|_| H3Error::Protocol("Invalid JSON".into()))?;
// Create user (mock)
let user = CreateUserResponse {
id: 123,
username: create_req.username,
email: create_req.email,
};
// Send JSON response
let response_json = serde_json::to_vec(&user)
.map_err(|_| H3Error::Protocol("JSON serialization failed".into()))?;
response.send_response(
201,
vec![
("content-type".into(), "application/json".into()),
],
Some(Bytes::from(response_json)),
).await
}
("GET", path) if path.starts_with("/api/users/") => {
// Extract user ID from path
let user_id = path.trim_start_matches("/api/users/");
// Mock response
let user = CreateUserResponse {
id: user_id.parse().unwrap_or(0),
username: "john_doe".into(),
email: "john@example.com".into(),
};
let response_json = serde_json::to_vec(&user)
.map_err(|_| H3Error::Protocol("JSON serialization failed".into()))?;
response.send_response(
200,
vec![("content-type".into(), "application/json".into())],
Some(Bytes::from(response_json)),
).await
}
_ => {
response.send_response(
404,
vec![],
Some(Bytes::from("Not Found")),
).await
}
}
}
}

For large responses (video, logs), use streaming:

#[async_trait]
impl H3Handler for StreamingHandler {
async fn handle_request(
&self,
request: H3Request,
response: H3ResponseSender,
) -> Result<(), H3Error> {
if request.path == "/stream" {
// Get streaming send handle
let send_stream = response.send_response_streaming(
200,
vec![("content-type".into(), "text/plain".into())],
).await?;
// Stream data in chunks
for i in 0..10 {
let chunk = format!("Chunk {}\n", i);
send_stream.write(Bytes::from(chunk), false).await
.map_err(|e| H3Error::Connection(format!("write error: {:?}", e)))?;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
// Send FIN
send_stream.finish().await
.map_err(|e| H3Error::Connection(format!("finish error: {:?}", e)))?;
Ok(())
} else {
response.send_response(404, vec![], Some(Bytes::from("Not Found"))).await
}
}
}

For POST/PUT requests with body data:

async fn read_body(mut recv_stream: RecvStream) -> Result<Vec<u8>, H3Error> {
let mut body = Vec::new();
loop {
match recv_stream.read().await {
Ok(Some(quicd_x::StreamData::Data(chunk))) => {
body.extend_from_slice(&chunk);
}
Ok(Some(quicd_x::StreamData::Fin)) => break,
Ok(None) => break,
Err(e) => return Err(H3Error::Connection(format!("read error: {:?}", e))),
}
}
Ok(body)
}

use std::sync::Arc;
pub struct MiddlewareChain<H: H3Handler> {
inner: Arc<H>,
middlewares: Vec<Arc<dyn Middleware>>,
}
#[async_trait]
pub trait Middleware: Send + Sync {
async fn process(
&self,
request: &mut H3Request,
) -> Result<Option<H3Response>, H3Error>;
}
// Example: Authentication middleware
pub struct AuthMiddleware {
api_key: String,
}
#[async_trait]
impl Middleware for AuthMiddleware {
async fn process(&self, request: &mut H3Request) -> Result<Option<H3Response>, H3Error> {
let auth_header = request.headers.iter()
.find(|(k, _)| k == "authorization")
.map(|(_, v)| v);
if let Some(auth) = auth_header {
if auth == &format!("Bearer {}", self.api_key) {
return Ok(None); // Continue to handler
}
}
// Return 401 Unauthorized
Ok(Some(H3Response {
status: 401,
headers: vec![("www-authenticate".into(), "Bearer".into())],
body: Some(Bytes::from("Unauthorized")),
}))
}
}

Terminal window
# Build curl with HTTP/3 support
# See https://github.com/curl/curl/blob/master/HTTP3.md
curl --http3 https://localhost:8443/
Terminal window
cargo install quiche-client
quiche-client https://localhost:8443/

Modern browsers support HTTP/3:

  • Chrome/Edge: Enable chrome://flags/#enable-quic
  • Firefox: Set network.http.http3.enabled to true

Note: Browsers won’t accept self-signed certificates. Use proper CA-signed certs for testing.


HTTP/3 connections are persistent and multiplexed. Clients should reuse connections:

// Client-side (conceptual)
let connection = connect_to_server().await?;
// Send multiple requests on same connection
for path in ["/api/users", "/api/posts", "/api/comments"] {
let stream = connection.open_bi().await?;
send_request(stream, path).await?;
}

For high-throughput APIs, increase flow control limits:

[quic]
initial_max_data = 50000000 # 50MB for large responses
initial_max_stream_data_bidi_remote = 10000000 # 10MB per stream

HTTP/3 allows many concurrent requests. Tune stream limits:

[quic]
initial_max_streams_bidi = 500 # Support 500 concurrent requests

Simple path-based routing:

match request.path.as_str() {
"/" => handle_index(response).await,
"/api/users" => handle_users(request, response).await,
path if path.starts_with("/static/") => serve_static(path, response).await,
_ => response.send_response(404, vec![], Some(Bytes::from("Not Found"))).await,
}
match handle_request_logic(&request).await {
Ok(body) => response.send_response(200, vec![], Some(body)).await,
Err(e) => {
eprintln!("Error: {:?}", e);
response.send_response(
500,
vec![],
Some(Bytes::from("Internal Server Error")),
).await
}
}
// Set custom headers
let headers = vec![
("content-type".into(), "application/json".into()),
("cache-control".into(), "max-age=3600".into()),
("x-custom-header".into(), "value".into()),
];
response.send_response(200, headers, Some(body)).await


HTTP/3 on QuicD is production-ready. Build fast, modern web applications with zero head-of-line blocking.