How to Make Async HTTP Requests with reqwest

Make async HTTP requests in Rust by adding reqwest to Cargo.toml and using await with reqwest::get() inside an async function.

When the thread shouldn't wait

You're used to fetch in JavaScript or requests.get in Python. You write a line, you get data. In Rust, you write reqwest::get, the compiler screams about async contexts, and suddenly you need a runtime. The mental model shifts from "blocking the thread" to "handing work to an event loop."

Rust's reqwest crate is the standard for HTTP clients. It embraces Rust's async ecosystem. This means requests don't freeze your thread while waiting for the network. Instead, they yield control back to the runtime, allowing your program to handle thousands of concurrent connections with minimal memory overhead.

The async mental model

Async code in Rust is built on the Future trait. A Future is a state machine that tracks progress toward a result. It doesn't do work on its own. It needs an executor to poll it.

Think of a busy restaurant kitchen. A waiter takes an order and hands it to the chef. The waiter doesn't stand at the stove waiting for the food. The waiter takes more orders, checks on other tables, and returns when the chef signals the dish is ready. The waiter is the runtime. The order ticket is the Future. The .await keyword is the waiter stepping away to do other work until the ticket is marked done.

In Rust, #[tokio::main] sets up the waiter. Functions marked async return tickets. The .await expression tells the waiter to pause this ticket and resume it later.

Async code doesn't run by itself. It needs an executor to drive the state machine.

Minimal example

Here is the smallest working program. It fetches a page and prints the body.

use reqwest;

// The tokio runtime drives async tasks. Without this, futures never execute.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // reqwest::get returns a Future. .await yields control until the request completes.
    let response = reqwest::get("https://www.rust-lang.org").await?;
    
    // Reading the body is also async. The data streams in chunks.
    let body = response.text().await?;
    
    println!("{body}");
    Ok(())
}

Add these dependencies to your Cargo.toml. The json feature enables JSON parsing methods. tokio provides the runtime.

[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }

The compiler rejects .await outside an async function with a "cannot await in a non-async context" error. If you forget the runtime attribute, you'll see errors about futures not being executed.

How the request flows

When you call reqwest::get, the function builds a request object and returns a Future. Nothing happens yet. No DNS lookup, no connection.

When you .await, the runtime takes the future and polls it. The future performs the first step: DNS resolution. If the DNS server is busy, the future registers a callback with the runtime and returns "not ready". The runtime switches to another task.

When the DNS response arrives, the runtime wakes the future. It resumes execution. The future opens a TCP connection. If the connection is pending, it yields again. This continues through the TLS handshake and the HTTP request.

Once the server responds, the future resumes. The Response object contains headers and a stream for the body. Calling .text().await reads the stream until the end, buffering the content into a String.

The .await keyword is a suspension point, not a block.

The Client pattern

The minimal example uses reqwest::get. This is a convenience function that creates a new client, sends the request, and drops the client. In real applications, you should reuse a Client.

Creating a client sets up connection pools, TLS session caches, and default headers. Reusing the client avoids the overhead of re-establishing these resources for every request.

use reqwest;
use serde::Deserialize;

#[derive(Deserialize)]
struct User {
    id: u64,
    name: String,
}

/// Fetches a user from the API and prints details.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Reusing a Client enables connection pooling and keeps TLS sessions alive.
    let client = reqwest::Client::new();
    
    // Build the request explicitly. This gives you control over headers and timeouts.
    let response = client
        .get("https://jsonplaceholder.typicode.com/users/1")
        .send()
        .await?;
    
    // Check status before parsing. reqwest doesn't error on 4xx/5xx by default.
    if !response.status().is_success() {
        return Err("Request failed".into());
    }
    
    let user: User = response.json().await?;
    println!("User: {} ({})", user.name, user.id);
    Ok(())
}

Add serde to your dependencies for JSON parsing.

serde = { version = "1", features = ["derive"] }

Cloning a Client is cheap. It shares the underlying connection pool via reference counting. The community convention is to create one client per application and clone it wherever you need to make requests.

Clone the client, not the connection pool.

Errors and status codes

reqwest distinguishes between network errors and HTTP errors. A network error occurs when DNS fails, the connection drops, or TLS negotiation fails. These return Err from .await.

An HTTP error like 404 or 500 is a valid response from the server. reqwest returns Ok(Response) for these. You must check the status code manually. This differs from Python's requests, which can raise exceptions on HTTP errors.

let response = client.get(url).await?;

// Network succeeded, but the server might have returned an error.
if response.status().is_server_error() {
    eprintln!("Server error: {}", response.status());
    return Err("Server failed".into());
}

Always check the status code. A 404 is success for the network, failure for the logic.

Pitfalls

Blocking the executor is the most common mistake. If you call a blocking function like std::thread::sleep or a synchronous database driver inside an async function, you freeze the runtime thread. Other tasks can't make progress.

Use async-compatible alternatives. For sleeping, use tokio::time::sleep. For databases, use async drivers like sqlx.

Another pitfall involves thread safety. The default tokio runtime is multi-threaded. Futures must be Send to move between threads. If you capture a Rc inside an async block, the compiler rejects it because Rc is not Send.

The compiler rejects this with E0277 (the trait bound Rc<T>: Send is not satisfied). Use Arc instead of Rc in async code. Arc is thread-safe.

Never call blocking code inside an async function. The runtime will stall.

Decision matrix

Use reqwest with async/await when your application handles concurrent requests or integrates with an async ecosystem like a web server. Use reqwest::blocking when you are writing a simple script, a migration tool, or a CLI command where synchronous flow is clearer and concurrency adds no value. Use ureq when dependency size matters and you need a lightweight HTTP client without the overhead of an async runtime. Reach for hyper when you are building a server or require fine-grained control over the HTTP protocol implementation.

Pick the tool that matches your concurrency needs, not your nostalgia for synchronous code.

Where to go next