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.

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
[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
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
matchon method and path. Path parameters, a router and 405 responses withAlloware 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 withhttp_body_util::Limitedor 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 usehyper_util::server::graceful::GracefulShutdownto drain on a signal. - HTTP/2 and TLS: pick
http1orhttp2builders or the auto builder fromhyper-util, and terminate TLS withtokio-rustlsbefore handing the stream over.
What a framework adds
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
- A tiny reverse proxy or health endpoint sidecar, where the whole program is a few dozen lines and dependencies matter.
- A library that exposes an HTTP interface but must not force a framework on its users.
- A custom protocol, such as a CONNECT tunnel, upgrade handling or unusual streaming, that a framework's abstractions get in the way of.
- 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).


