Skip to content
rusthacks.dev
Web

Hyper Directly in Rust and When to Skip the Framework

Hyper 1.x is a low-level HTTP library: you write the accept loop, the service and the routing. See a working server with hyper-util, what a framework adds, and the narrow cases where going direct is right.

3 min read
Summarize in AI
Flat illustration for the post “Hyper Directly in Rust and When to Skip the Framework”, composed around web.

Most Rust web code never touches Hyper directly, and that is fine. But knowing what it does makes the frameworks less magical, and a few jobs are small enough that a framework would be the larger part of the program. This post builds a working server on Hyper 1.x. It compiles on Rust 1.90 with edition 2024, Hyper 1.6, hyper-util 0.1, http-body-util 0.1 and Tokio 1.47.

Dependencies

TOML
[dependencies]
hyper = { version = "1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
http-body-util = "0.1"
bytes = "1"
tokio = { version = "1.47", features = ["full"] }

A complete server

rust
use std::{convert::Infallible, net::SocketAddr};

use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::{body::Incoming, service::service_fn, Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;

async fn handle(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
    let res = match (req.method(), req.uri().path()) {
        (&Method::GET, "/") => Response::new(Full::new(Bytes::from("hello"))),
        (&Method::POST, "/echo") => {
            let body = req.into_body().collect().await.map(|b| b.to_bytes()).unwrap_or_default();
            Response::new(Full::new(body))
        }
        _ => Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(Full::new(Bytes::from("not found")))
            .unwrap(),
    };
    Ok(res)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = TcpListener::bind(addr).await?;
    loop {
        let (stream, _) = listener.accept().await?;
        let io = TokioIo::new(stream);
        tokio::spawn(async move {
            if let Err(e) = hyper::server::conn::http1::Builder::new()
                .serve_connection(io, service_fn(handle))
                .await
            {
                eprintln!("connection error: {e}");
            }
        });
    }
}

Walking through it: the loop accepts a TCP connection, wraps it in TokioIo (an adapter from Tokio's I/O traits to Hyper's), and spawns a task that drives an HTTP/1 connection with service_fn(handle) as the service. handle is an async function from a request to a response; here its error type is Infallible, meaning it always produces a response. The body type is Full<Bytes>, a body that is one chunk.

What you are now responsible for

  • Routing. A match on method and path. Path parameters, a router and 405 responses with Allow are yours to write.
  • Body handling. Incoming bodies arrive as a stream of frames. collect() buffers all of it, so you must impose a size limit with http_body_util::Limited or you accept unbounded input (resource limits).
  • Content negotiation, JSON, forms, cookies: parse headers and bodies yourself.
  • Timeouts and graceful shutdown: wrap the connection future in tokio::time::timeout, and use hyper_util::server::graceful::GracefulShutdown to drain on a signal.
  • HTTP/2 and TLS: pick http1 or http2 builders or the auto builder from hyper-util, and terminate TLS with tokio-rustls before handing the stream over.

What a framework adds

rust
use axum::{routing::{get, post}, Router};

fn app() -> Router {
    Router::new()
        .route("/", get(|| async { "hello" }))
        .route("/echo", post(|body: String| async move { body }))
}

This is the same server. Axum's serve contains the accept loop and per-connection tasks shown above, plus TLS-friendly hooks and graceful shutdown. Its extractors parse bodies with limits, its router handles methods and parameters, and its layers add tracing, timeouts and compression through the Tower ecosystem (Tower services and layers). The cost over raw Hyper is small, since Axum's own code is thin and the heavy lifting is Hyper's.

When direct Hyper is the right call

  1. A tiny reverse proxy or health endpoint sidecar, where the whole program is a few dozen lines and dependencies matter.
  2. A library that exposes an HTTP interface but must not force a framework on its users.
  3. A custom protocol, such as a CONNECT tunnel, upgrade handling or unusual streaming, that a framework's abstractions get in the way of.
  4. Learning: writing a server once makes error messages from Axum and reqwest far easier to read.

Otherwise start with a framework. The migration from direct Hyper later is easy, since a service_fn maps to an Axum handler, but the reverse, adding extractors, limits and middleware by hand, is the work you chose to skip. For the client side, reqwest is to hyper-util's client what Axum is to the server (reqwest error handling).

Taggedhyper
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.