How to Use HTTP/2 in Rust

Web
Use the `hyper` crate with the `full` feature flag to enable HTTP/2 support, as it is the standard high-performance HTTP library for Rust.

When HTTP/1.1 becomes a bottleneck

You are building a dashboard that aggregates data from five microservices. With HTTP/1.1, the client opens a separate TCP connection for each request, or queues them behind one another. Latency stacks up. The user watches a loading spinner. HTTP/2 solves this by multiplexing multiple requests over a single connection. It compresses headers. It prioritizes streams. You get the performance without writing a binary frame parser from scratch.

Rust handles HTTP/2 through the hyper ecosystem. hyper is the standard high-performance HTTP library. It abstracts the binary framing and multiplexing logic, letting you write standard async Rust code. The crate uses the h2 crate internally for the protocol mechanics, but you interact with hyper's type-safe API.

How HTTP/2 changes the game

HTTP/1.1 sends text-based messages over TCP. Each request-response pair can block others on the same connection. HTTP/2 switches to a binary framing layer. It splits messages into frames and interleaves them on a single TCP connection. This is multiplexing. Multiple streams share the same connection without head-of-line blocking.

Think of HTTP/1.1 like a single-lane road where cars must wait for the vehicle ahead to finish its trip. HTTP/2 is a multi-lane highway. Cars can pass each other. The toll booth remembers your license plate, so you do not show identification every time. That is header compression via HPACK.

Browsers and most clients require TLS for HTTP/2. The TLS handshake negotiates the protocol using ALPN (Application-Layer Protocol Negotiation). The client and server agree on HTTP/2 before any application data flows. Cleartext HTTP/2 (h2c) exists for testing, but production systems almost always use TLS.

Minimal client example

The hyper crate enables HTTP/2 support through feature flags. You need the full feature to get the client builder methods and protocol support.

use hyper::Client;
use hyper::Body;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Build a client that speaks HTTP/2 by default.
    // The 'full' feature in Cargo.toml enables the build_http2 method.
    let client = Client::builder()
        .build_http2();

    // Send a GET request.
    // hyper handles the binary framing, multiplexing, and HPACK compression.
    let res = client.get("https://http2.akamai.com/demo").await?;

    println!("Status: {}", res.status());
    println!("Headers: {:?}", res.headers());

    Ok(())
}

The build_http2() method forces the client to use HTTP/2. If the server does not support HTTP/2, the connection fails. This is useful when you know the target supports HTTP/2 and want to avoid negotiation overhead.

ALPN negotiation versus forced HTTP/2

You have two ways to configure the client protocol. The choice depends on what you know about the server.

Use Client::builder().build_http2() when you are talking to a server that guarantees HTTP/2 support and you want to skip the ALPN negotiation step. This is common for internal microservices where you control both sides.

Use Client::builder().build() when you need to talk to servers that might support HTTP/1.1 or HTTP/2. The client performs ALPN negotiation during the TLS handshake. If the server supports HTTP/2, the connection upgrades automatically. If not, it falls back to HTTP/1.1. This is the safe default for public internet requests.

The build() method returns a client that negotiates the protocol. The build_http2() method returns a client that demands HTTP/2. Mixing these up causes runtime errors. If you use build_http2() against an HTTP/1.1-only server, the request fails with a protocol error. If you use build() against an HTTP/2 server, you get HTTP/2 performance automatically.

Feature flags and dependencies

The hyper crate splits functionality into features to keep compile times low. HTTP/2 support lives behind the http2 feature. The full feature enables http2, client, server, tcp, and other common dependencies.

Convention in the Rust community is to use full for most applications. It avoids the friction of missing methods due to disabled features. If you are building a library and want to minimize dependencies, you can enable http2 and client separately. Just remember that build_http2() requires the http2 feature.

[dependencies]
hyper = { version = "1.0", features = ["full"] }
tokio = { version = "1", features = ["full"] }
tokio-rustls = "0.26"

The tokio runtime is required because hyper is async. The tokio-rustls crate provides TLS support. hyper does not include TLS. You must bring your own TLS implementation. tokio-rustls is the community favorite for its pure Rust implementation and performance. native-tls is an alternative that wraps the system's TLS library.

Realistic server example

Building an HTTP/2 server requires more setup than the client. You need to bind a TCP listener, wrap connections with TLS, and serve requests using hyper's connection handler.

use hyper::{Body, Request, Response, server::conn::Http};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;

// Handler function for incoming requests.
// It must be async and return a Result.
async fn handle_request(req: Request<Body>) -> Result<Response<Body>, hyper::Error> {
    // Return a simple response.
    // HTTP/2 handles the framing and compression automatically.
    Ok(Response::new(Body::from("Hello over HTTP/2")))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Bind to localhost on port 8443.
    let addr = "[::1]:8443".parse()?;
    let listener = TcpListener::bind(addr).await?;
    println!("Listening on {}", addr);

    // Create a TLS acceptor.
    // In production, load your certificate and private key from files.
    // This example assumes you have a TlsAcceptor configured.
    let acceptor = TlsAcceptor::from(/* rustls config */);

    // Accept loop for incoming connections.
    loop {
        // Accept a TCP connection.
        let (stream, _) = listener.accept().await?;

        // Wrap the TCP stream with TLS.
        // HTTP/2 requires the stream to be encrypted in production.
        let tls_stream = acceptor.accept(stream).await?;

        // Create the service function.
        let service = hyper::service::service_fn(handle_request);

        // Spawn the connection handler.
        // This allows the loop to accept new connections while serving existing ones.
        tokio::spawn(async move {
            // Serve the connection using HTTP/2.
            // Http::new() creates the connection handler.
            // serve_connection handles the framing and multiplexing.
            if let Err(err) = Http::new()
                .serve_connection(tls_stream, service)
                .await
            {
                eprintln!("Connection error: {}", err);
            }
        });
    }
}

The server loop accepts TCP connections, wraps them with TLS, and passes them to Http::new().serve_connection(). The serve_connection method handles the HTTP/2 protocol. It manages streams, flow control, and header compression. You just provide the handler function.

The handler must be async. If you return a sync function, the compiler rejects you with E0277 (trait bound not satisfied) because the service expects an async future. The handler signature must match async fn(Request<Body>) -> Result<Response<Body>, Error>.

Convention aside: Keep the unsafe surface zero. hyper and tokio-rustls handle all the low-level details. You should never need unsafe blocks for standard HTTP/2 servers. If you find yourself writing unsafe, you are likely misusing the API or implementing a custom protocol extension.

Pitfalls and compiler errors

HTTP/2 introduces a few gotchas. The compiler catches some at build time. Others manifest at runtime.

If you forget the http2 feature in Cargo.toml, the build_http2 method does not exist. The compiler rejects the code with a method not found error. Enable full or http2 to fix this.

If you use build_http2() against a server that only supports HTTP/1.1, the connection fails. The error is a runtime protocol error. Use build() for ALPN negotiation if you are unsure about the server's capabilities.

If you try to serve HTTP/2 without TLS, browsers will refuse to connect. Most clients require TLS for HTTP/2. Cleartext HTTP/2 (h2c) is rare and usually disabled by default. Wrap your listener with tokio-rustls or native-tls to satisfy the requirement.

If your handler returns the wrong type, the compiler rejects you with E0308 (mismatched types). Ensure the handler returns Result<Response<Body>, hyper::Error> or a compatible error type. The hyper::Error type implements the standard Error trait, so you can use Box<dyn std::error::Error> if needed.

If you forget to spawn the connection handler, the server blocks on the current connection and cannot accept new ones. Always spawn the serve_connection future in a separate task. The tokio::spawn call is essential for concurrency.

Don't skip TLS. The web has moved on. Browsers and clients expect encryption.

Decision matrix

Choose the right tool based on your needs. The ecosystem offers options for different levels of abstraction.

Use hyper with the full feature when you need maximum control over HTTP/2 settings, stream prioritization, or are building a library that other crates will depend on.

Use reqwest when you just need to make HTTP/2 requests as a client and want a high-level API that handles redirects, cookies, and TLS configuration automatically.

Use axum when you are building a web server and want routing, middleware, and structured error handling on top of hyper's HTTP/2 support.

Use the h2 crate directly when you are implementing a custom protocol that sits on top of HTTP/2 frames and hyper is too high-level.

Trust the borrow checker. It usually has a point. Reach for hyper when you need performance and control. Reach for axum when you need productivity.

Where to go next