hyper: The HTTP Library
Here's the mental model to carry through this whole phase, and honestly through the
rest of the guide: hyper is the thing that speaks HTTP on the socket. It reads
the raw bytes a client sent, parses them into a Request, hands that request to a
function you wrote, waits for you to give back a Response, and writes that response
back out as bytes — correctly, including all the fiddly HTTP/1 and HTTP/2 framing you
never want to implement yourself.
That's it. That's the job. hyper is the HTTP engine. It is not a web framework, and the difference matters more than you'd expect — we'll get to exactly what it leaves out, and why leaving it out is a feature, not a gap.
💡 If you remember one sentence: hyper takes a
Requestoff the wire and calls your code, expecting aResponse. Everything else in this phase is detail about what those two types are and how you wire the loop together.
The types come from the http crate
hyper doesn't invent its own request and response types. It uses the ones from the
http crate — a tiny, dependency-light crate that the whole Rust HTTP ecosystem
shares. That sharing is the point: hyper, axum, reqwest, and tonic all agree on what
a Request is, so a request value can pass between them without conversion.
The two stars are generic over their body:
// from the `http` crate
What just happened: Request<B> and Response<B> bundle the metadata you'd
expect — method, URI, status code, headers — with a body whose type is a parameter
B. The metadata is always the same shape; the body type varies depending on where
the value is in its life. An incoming request's body and an outgoing response's body
are different concrete types, and that's normal.
Bodies are streams of bytes (the Body trait)
Why is the body a type parameter instead of, say, a Vec<u8>? Because an HTTP body
isn't always a finished buffer sitting in memory. It might be a 4 GB file streaming
in chunk by chunk, or a server-sent-events stream that never really ends. So hyper
abstracts "a body" behind a trait — http_body::Body — which describes a thing you
can pull data frames from over time, rather than a fixed blob.
You'll meet a few concrete body types constantly:
hyper::body::Incoming— the body of a request that just arrived. hyper hands you this; you read from it. You don't construct it.http_body_util::Full<Bytes>— a whole buffer you already have in memory, presented as a (one-chunk) body. The everyday choice for a simple response.http_body_util::Empty<Bytes>— a body with no data at all (a204, say).
use Full;
use Bytes;
// A response whose body is one complete buffer:
let body = new;
What just happened: Bytes is a cheap-to-clone reference-counted byte buffer (from
the bytes crate). Wrapping it in Full says "this is the entire body, all at
once." Full<Bytes> implements the Body trait, so hyper knows how to write it to
the socket. For most handlers returning a string or a JSON blob, Full<Bytes> is all
you need.
⚠️ Don't reach for the
Bodytrait's methods (poll_frameand friends) by hand unless you're building streaming machinery. Day to day you pick a ready-made body type and move on. The trait is there so the plumbing is generic, not so you write plumbing.
You hand hyper a service; it calls you
hyper needs something to call for each request. That something is a service: in
spirit, an async function from Request to Response. (Phase 3 makes "service" a
precise trait — for now, "async fn request → response" is the right picture.)
The quickest way to make one is service_fn, which turns an async closure or function
into a service hyper accepts:
use Full;
use ;
async
let service = service_fn;
What just happened: handle takes a request whose body is Incoming (it came off
the wire) and returns a Result of a response whose body is Full<Bytes> (we built
it in memory). Notice the body types differ on the way in versus the way out —
exactly as promised. service_fn(handle) wraps the function into the shape hyper
wants to call. We ignore the request here (_req), because there's no router yet to
look at the path — every request gets the same reply.
Serving a connection
hyper is runtime-agnostic in 1.x: its core doesn't know about Tokio, threads, or
sockets. That keeps the HTTP logic portable, but it means you have to bridge hyper to
whatever I/O you're actually using. With Tokio (the usual choice — see
/guides/tokio-the-async-runtime), that bridge is
hyper_util::rt::TokioIo, a thin adapter that wraps a Tokio TCP stream so hyper
can read and write through it.
The shape of serving one connection looks like this:
use http1;
use TokioIo;
// `tcp_stream` is a tokio::net::TcpStream you got from accepting a connection.
let io = new;
new
.serve_connection
.await?;
What just happened: TokioIo::new wraps the raw Tokio socket into something hyper's
runtime-agnostic core can drive. http1::Builder::new().serve_connection(...) then
runs the HTTP/1 protocol over that connection: it parses incoming requests, calls
your service for each one, writes the responses, and keeps the connection alive for
keep-alive requests until the client goes away. .await drives that to completion for
this one connection.
📝 This is the shape, not a paste-and-run server. A real program adds a
tokio::net::TcpListeneraccept loop, spawns a task per connection, and handles errors — and most people reach forhyper_util::server::conn::autoinstead ofhttp1so a single setup serves both HTTP/1 and HTTP/2. The takeaway here is the three moving parts: adapt the socket (TokioIo), pick a protocol builder, hand it a service.
What hyper does not give you
This is the part that surprises people coming from Express, Flask, or even axum. Look
again at handle: it received a Request and the only thing it could do was build a
Response. There was:
- No router. hyper does not look at the path or method for you.
GET /users/42andPOST /loginarrive at the same function; matching them is your problem. - No path parameters, no query parsing helpers. You get a
Uri; pulling42out of/users/42is on you. - No middleware. No built-in logging, auth, CORS, compression, or timeouts.
- No JSON helpers. No
req.json(), no automatic serialization. You read raw bytes and call serde yourself.
// With bare hyper, you'd route by hand — something like:
match
What just happened: that hand-rolled match is the kind of thing a framework
generates for you. With bare hyper you'd write it, and it'd get unwieldy fast as
routes multiply — which is precisely why frameworks exist. hyper deliberately stops
at "raw request in, raw response out."
⚠️ That bareness is the whole design philosophy, not an oversight. hyper aims to be a small, fast, correct HTTP implementation that everything else can build on. Routing, middleware, and JSON are opinions; hyper stays out of opinions so axum, tonic, and your own code can layer theirs on top. When you understand that hyper is "just" the HTTP engine, the rest of the stack — services, layers, axum — clicks into place, because you can see exactly what they're adding.
The 1.0 split: hyper-util and http-body-util
You've now seen three crates working together, and that's intentional. When hyper hit 1.0, it pushed conveniences out of the core into companion crates so the core itself could stay small and promise long-term stability:
hyper— the stable core: protocol,Incoming,serve_connection,service_fn.hyper-util— runtime glue and helpers that aren't part of the stable guarantee:TokioIo, theautoconnection server, client pools.http-body-util— ready-made body types:Full,Empty, and combinators.
📝 The logic: a 1.0 means a strong backward-compatibility promise. The smaller the stable surface, the easier that promise is to keep. So hyper kept only the truly stable HTTP core under the 1.0 umbrella and parked the more-likely-to-evolve helpers in
-utilcrates. That's why importing from three crates to write one server is normal here — it's a deliberate split, not boilerplate sprawl.
Recap
- hyper speaks HTTP on the socket: it parses bytes into a
Request, calls your service, and writes yourResponseback out. It's the HTTP engine, not a framework. - The types come from the
httpcrate:Request<B>andResponse<B>, generic over a body typeB, shared across the whole ecosystem. - Bodies are streams behind the
Bodytrait. You usually pick a concrete type:Incomingfor arriving requests,Full<Bytes>for an in-memory response. - You hand hyper a service (quickest:
service_fn), and serve a connection by adapting the socket withTokioIoand running a protocol builder'sserve_connection. hyper's core is runtime-agnostic;hyper-utilbridges it to Tokio. - hyper gives you no router, middleware, or JSON — on purpose. That bareness is what lets frameworks build on it.
- hyper 1.0 split conveniences into
hyper-utilandhttp-body-utilto keep the stable core small.
Quick check
[
{
"q": "In hyper 1.x, what does TokioIo do?",
"choices": [
"Parses the HTTP request into a Request value",
"Adapts a Tokio TCP socket so hyper's runtime-agnostic core can drive it",
"Routes requests to the correct handler by path",
"Serializes a struct into a JSON response body"
],
"answer": 1,
"explain": "hyper's core doesn't know about Tokio. TokioIo (from hyper-util) wraps a Tokio stream so hyper can read and write through it."
},
{
"q": "Which body type would you typically use for a response that's a complete in-memory buffer?",
"choices": [
"hyper::body::Incoming",
"http_body_util::Empty<Bytes>",
"http_body_util::Full<Bytes>",
"Vec<u8>"
],
"answer": 2,
"explain": "Full<Bytes> presents a whole buffer as a one-chunk body. Incoming is for arriving requests; Empty is for no body at all."
},
{
"q": "Which of these does bare hyper give you out of the box?",
"choices": [
"A router that matches paths to handlers",
"Middleware for logging and CORS",
"JSON request/response helpers",
"The parsed Request and the raw connection, and nothing more"
],
"answer": 3,
"explain": "hyper hands you the request and the connection and stops there. Routing, middleware, and JSON are deliberately left to frameworks built on top of it."
}
]
← Phase 1: What hyper & tower Are · Guide overview · Phase 3: The Service Trait →
Check your understanding
1. In hyper 1.x, what does TokioIo do?
2. Which body type would you typically use for a response that's a complete in-memory buffer?
3. Which of these does bare hyper give you out of the box?