When a simple request feels like a puzzle
You just finished a Python Flask app or a Node Express server. You want to rewrite it in Rust for speed and memory safety. You hear about hyper. You run cargo add hyper, paste a tutorial snippet, and hit run. The compiler returns a wall of trait bound errors, missing async executor warnings, and complaints about stream types. The crate is not broken. Your mental model just needs to shift.
Rust does not bundle a global event loop. It does not hide network I/O behind synchronous callbacks. It expects you to pick the pieces that fit your architecture. hyper is one of those pieces. It is the most widely used HTTP engine in the Rust ecosystem, but it is not a full-stack framework. It is a high-performance protocol parser and socket driver. You bring the runtime. You bring the routing logic. You bring the body handling utilities. When those pieces click together, you get a server that handles thousands of concurrent connections with a fraction of the memory overhead of traditional frameworks.
The engine, not the car
Think of hyper like a high-performance carburetor. It knows exactly how to parse HTTP headers, encode status codes, and push bytes across a TCP socket. It does not care about your database, your template engine, or your authentication middleware. It only cares about the HTTP specification and moving data efficiently.
In hyper 1.0, the crate was split into focused components. The core library now handles the HTTP protocol strictly. Request and response bodies are no longer eager buffers. They are async streams of byte chunks. This change eliminates unnecessary memory copies and lets you process large payloads without loading them entirely into RAM. The tradeoff is that you must explicitly collect or iterate over those chunks. The compiler will not guess what you want to do with a stream.
You also need an async runtime. hyper does not include one. It only provides the traits that runtimes implement. tokio is the standard choice because it is mature, widely supported, and integrates cleanly with hyper's executor trait. When you combine them, hyper hands I/O operations to tokio's event loop, which schedules them across OS threads without blocking your application logic.
A minimal client and server
Here is a self-contained example that shows both sides of the protocol. It uses hyper 1.0, tokio, and http-body-util for stream handling.
// Cargo.toml dependencies:
// hyper = { version = "1.0", features = ["full"] }
// tokio = { version = "1", features = ["full"] }
// http-body-util = "0.1"
use hyper::{body::Bytes, Client, Request, Response, Server, service::service_fn};
use http_body_util::{BodyExt, Full};
use std::convert::Infallible;
/// Fetches a URL and prints the response body.
async fn run_client() -> Result<(), Box<dyn std::error::Error>> {
// Build the client with tokio's executor.
let client = Client::builder(hyper::rt::TokioExecutor::new()).build_http();
// Construct a GET request to a test endpoint.
let req = Request::builder()
.uri("https://httpbin.org/get")
.body(hyper::body::Empty::new())?;
// Send the request and wait for the response.
let res = client.request(req).await?;
// Collect the streaming body into a single byte buffer.
let body = res.into_body().collect().await?.to_bytes();
println!("Client received: {}", String::from_utf8_lossy(&body));
Ok(())
}
/// Creates a service that responds to every request.
async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Bind to localhost on port 3000.
let addr = ([127, 0, 0, 1], 3000).into();
// Define the service factory for incoming connections.
let make_svc = || {
// Wrap an async closure that returns a response.
let service = service_fn(|_req| async {
// Build a static response body from bytes.
let body = Full::new(Bytes::from("Hello, Hyper!"));
Ok::<_, Infallible>(Response::new(body))
});
Ok::<_, Infallible>(service)
};
// Start the server and wait for graceful shutdown.
let server = Server::bind(&addr).serve(make_svc);
println!("Server listening on http://{}", addr);
server.await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Run the client first, then the server.
run_client().await?;
run_server().await?;
Ok(())
}
Run the client first to see the network call complete. Then run the server and hit http://localhost:3000 in your browser. You will see the static string returned. The code works because every async operation is handed to tokio, and every body is explicitly collected or wrapped in a stream-compatible type.
What happens under the hood
When you call Client::builder(...).build_http(), hyper allocates a connection pool and attaches tokio's executor to it. The executor is the bridge between hyper's I/O traits and tokio's event loop. Without it, hyper has no way to schedule socket reads or writes.
When you send a request, hyper serializes the HTTP method, URI, and headers into a byte buffer. It hands that buffer to the OS socket. The socket returns a future that resolves when data arrives. tokio polls that future on a background thread. When the response headers arrive, hyper parses them and hands you a Response object. The body is not a String. It is a stream of Bytes chunks. You must call .collect().await to pull those chunks into memory. If you skip that step, you are holding a stream that will never be read, and the connection will eventually time out.
On the server side, Server::bind(&addr) creates a TCP listener. serve(make_svc) starts accepting connections. For each new connection, hyper calls your make_svc closure to get a Service. The Service trait is the core abstraction: it takes a request, returns a future, and resolves to a response. service_fn is a convenience wrapper that turns an async closure into a Service. The Infallible error type tells the compiler your handler cannot fail. If your handler actually returns a Result<_, MyError>, you must map that error to an HTTP response or let the compiler reject the type mismatch.
Moving toward production code
Real applications do not return static strings. They parse JSON, route to handlers, and log errors. Here is a closer look at how a production-ready server shape looks.
use hyper::{body::Incoming, Request, Response, Server, service::service_fn};
use http_body_util::{BodyExt, Full};
use std::convert::Infallible;
/// Handles incoming HTTP requests and routes by path.
async fn handle_request(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
// Extract the request path for routing logic.
let path = req.uri().path().to_string();
// Choose a response body based on the route.
let body_bytes = match path.as_str() {
"/health" => Bytes::from("OK"),
"/api/data" => Bytes::from(r#"{"status": "active", "version": 1}"#),
_ => Bytes::from("Not Found"),
};
// Build the response with appropriate status codes.
let status = if path == "/health" || path == "/api/data" {
hyper::StatusCode::OK
} else {
hyper::StatusCode::NOT_FOUND
};
// Wrap bytes in a stream-compatible body type.
let body = Full::new(body_bytes);
Ok(Response::builder().status(status).body(body).unwrap())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = ([127, 0, 0, 1], 3000).into();
// Create a service that uses our handler function.
let make_svc = || {
let service = service_fn(handle_request);
Ok::<_, Infallible>(service)
};
let server = Server::bind(&addr).serve(make_svc);
println!("Production-style server on http://{}", addr);
server.await?;
Ok(())
}
Notice the explicit status code mapping and the unwrap() on the response builder. The builder can fail if you pass invalid headers or status codes, but in controlled routing logic you usually know the values are valid. The community convention here is to use unwrap() or expect("invalid status") rather than propagating the error, because a malformed status code is a programming mistake, not a runtime condition. Keep your error paths clean. Let the compiler catch logic bugs early.
Common traps and compiler signals
Missing the async runtime feature is the first wall you will hit. If you forget to enable tokio's rt or macros features, or if you skip hyper's client/server feature flags, the compiler rejects you with E0277 (trait bound not satisfied). The error points to hyper::rt::TokioExecutor or Server::bind and complains that a trait is not implemented. The fix is always in Cargo.toml. Enable the features you actually use. The community prefers explicit feature lists over full in production because full drives compile times up and bloats the binary.
Forgetting to collect a streaming body is the second trap. If you try to convert res.into_body() directly to a String, the compiler returns E0277 again, this time complaining that Incoming does not implement Into<String>. Bodies are streams. You must call .collect().await?.to_bytes() or iterate with .frame().await. Treat the body like a firehose. You have to open the nozzle and let the water fill a bucket.
Mixing up Infallible with real errors causes E0308 (mismatched types). If your handler returns Result<_, std::io::Error> but the service expects Infallible, the compiler refuses to unify the types. Either change the service signature to accept your error type and map it to an HTTP response, or stick with Infallible and handle failures before they reach the service layer. Pick one error strategy and commit to it.
Choosing your HTTP stack
Use hyper when you need maximum control over the HTTP protocol and want to build your own routing, middleware, or serialization layer from scratch. Use hyper when you are writing a library that other frameworks will depend on, and you need a stable, well-tested foundation. Use reqwest when you only need an HTTP client and want a high-level API that handles redirects, cookies, and JSON serialization automatically. Use axum when you want hyper under the hood but prefer ergonomic routing, typed extractors, and middleware composition without writing boilerplate. Use actix-web when you need a mature, synchronous-style API with a different async runtime and extensive built-in features.