rust.
rust10 min read

reqwest::Client connection pool reuse clone

Build one `reqwest::Client` per process and `clone()` it everywhere — cloning shares the inner `Arc` connection pool, while rebuilding per-request defeats keep-alive and starves the daemon, with `pool_max_idle_per_host` and `pool_idle_timeout` as the tuning knobs that matter.

reqwest::Client connection pool reuse clone

A friend pinged me last week about a Rust cron worker that was chewing through file descriptors on a small VM. Her p99 on a supposedly-quiet job had climbed past 300 ms, and every request lost the same hundred milliseconds before the request line even hit the wire. I've seen this shape enough times to guess the fix before I even asked to see her code. The problem was almost certainly the way she was building her reqwest::Client. This article is the fix, walked through in four commits with a companion repo you can clone and step through. By the end you'll know why Client::new() inside a request handler is a slow-throughput smell, why cloning a Client is cheap and safe, and which builder knobs are worth setting before you ship.

Step 1: The anti-pattern

The pattern below sails through code review on almost every Rust service under a year old (clean, compiles, tests green) and quietly costs a hundred milliseconds per request:

pub async fn fetch_naive(url: &str) -> reqwest::Result<String> {
    let client = Client::new();
    client.get(url).send().await?.text().await
}

The local client binding looks harmless because Client::new() returns quickly. Under the hood it constructs a fresh hyper connection pool wrapped in an Arc. The moment the function returns, the local binding drops, the Arc refcount hits zero, and the pool goes with it. Every socket it had parked for reuse is closed.

The next call runs the same code path. A brand new pool, a brand new TCP three-way handshake, and if the endpoint speaks HTTPS, a brand new TLS negotiation on top of that. Even against a well-behaved upstream on the same continent, TLS 1.3 with session resumption still adds one round-trip; without resumption, it is two. On a slow-network path you can easily spend 200 to 400 ms per request before the HTTP layer ever sees the response.

The measurement below comes from running the integration tests in the companion repo against a local wiremock server pinned to a 5 ms artificial response delay:

Pattern1 sequential call100 sequential calls (median per-call)Sockets opened
fetch_naive (Lesson 1)4.7 ms4.3 ms100
fetch_shared (Lesson 2)4.9 ms0.6 ms1

Localhost hides most of the pain because there is no real network. Over a real WAN with TLS the ratio widens; the shape stays identical, only the constant grows. That single-digit millisecond gap on localhost becomes a 100 to 300 ms gap in a production trace, which is exactly where the p99 climb comes from.

There is a second cost that does not show up in the timing column: file descriptor pressure. Each closed socket sits in TIME_WAIT for a couple of minutes on Linux by default, so a naive client doing 100 requests per second can pile up thousands of dead sockets even when only one connection is in flight at a time. On a small VM this is usually the failure mode you notice before you notice the p99 climb.

Try it: 56a7b41.

Step 2: One client, many clones

Cloning a Client in a hot loop feels like the kind of thing a Rust reviewer would flag, but it is the correct, idiomatic fix. It is also cheaper than the Arc::clone most engineers reach for instead. Build the client once, at startup, and pass a reference (or a clone) everywhere else:

pub fn build_shared_client() -> Client {
    Client::builder()
        .user_agent("reqwest-client-pool/0.1 (shared)")
        .build()
        .expect("client should build with default settings")
}

pub async fn fetch_shared(client: &Client, url: &str) -> reqwest::Result<String> {
    client.get(url).send().await?.text().await
}

Two things are worth noting about this shape.

First, you do not need Arc<Client>. The reqwest::Client docs call this out directly: the type already holds its inner state in an Arc. Wrapping again just costs you one extra pointer indirection and a .clone() that reads worse than the plain one. Storing a bare Client inside your application state (or inside a tokio::sync::Mutex when you truly need mutation, which is rare) is the idiomatic pattern.

Second, Client::clone() is O(1) and lock-free. It performs one atomic reference-count bump on the inner Arc. That means it is safe inside a hot loop, and there is no reason to try to "borrow the client across a spawn boundary" with contortions like Arc::clone around it. Every clone shares the exact same pool, the exact same DNS cache, and the exact same idle-socket bookkeeping.

The fan-out pattern in the integration test makes the semantics explicit:

let client = build_shared_client();
let mut handles = Vec::with_capacity(4);
for _ in 0..4 {
    let c = client.clone();
    let u = url.clone();
    handles.push(tokio::spawn(async move { fetch_shared(&c, &u).await }));
}

Four spawned tasks, four Client clones. All four requests get service from the same connection pool. If the upstream is keep-alive-friendly (nearly every modern server is; the behaviour dates back to RFC 7230 section 6.3), the four requests will reuse at most a handful of sockets rather than each opening its own.

Try it: 918a160.

Step 3: The five knobs that actually matter

Don't try to memorize the dozens of setters listed in the reqwest builder docs; the ones that actually shape pool behaviour under load are a much shorter list. Most of them do not change how the pool behaves under a steady request load. Five of them do:

KnobWhat it capsDefault in PoolConfigWhen to change it
pool_max_idle_per_hostidle sockets kept per (host, port)32Raise for high burst concurrency to one host; lower if the upstream complains about too many idle conns
pool_idle_timeouthow long an idle socket lives60 sSet below your upstream's keep-alive window so you close before it does
connect_timeoutmax time for a fresh TCP handshake5 sLower to fail fast on unreachable upstream; raise on very slow paths
timeouttotal per-request lifetime30 sSet to your worker's SLA budget; a missing value here means requests can hang forever
tcp_keepaliveOS-level TCP probe interval15 sSet on any long-lived client; NAT and firewall state expires without probes

The bundle looks like this:

pub struct PoolConfig {
    pub pool_max_idle_per_host: usize,
    pub pool_idle_timeout: Duration,
    pub connect_timeout: Duration,
    pub timeout: Duration,
    pub tcp_keepalive: Duration,
}

pub fn build_tuned_client(cfg: &PoolConfig) -> Client {
    Client::builder()
        .user_agent("reqwest-client-pool/0.1 (tuned)")
        .pool_max_idle_per_host(cfg.pool_max_idle_per_host)
        .pool_idle_timeout(cfg.pool_idle_timeout)
        .connect_timeout(cfg.connect_timeout)
        .timeout(cfg.timeout)
        .tcp_keepalive(cfg.tcp_keepalive)
        .build()
        .expect("tuned client should build with valid config")
}

Two of these get set wrong the most often.

pool_idle_timeout defaults to 90 seconds in current reqwest, which is longer than the 60-second window many upstreams use. When the server closes the socket first, your next request lands on a half-open connection and gets a hyper::Error(IncompleteMessage). Setting your own timeout below the server's keeps the process on the winning side of that race. If you are talking to nginx, 60 seconds beats the default keepalive_timeout 75s. If you are talking to a Cloudflare-fronted endpoint, 60 seconds beats the 100-second edge default. If you are running behind a Kubernetes ingress with its own tweaked value, ask the platform team what the ingress kills at and shave a couple of seconds off that number.

timeout is the other common miss. Leaving it as None (the reqwest default) reads like "wait patiently" but actually means "leak the worker if the upstream never responds". A specific number here (30 seconds is a decent default for JSON API calls; longer for document-generation endpoints) turns hangs into reqwest::Error::is_timeout() errors that the caller can retry. Pair this with a retry policy that respects is_timeout() versus is_connect() versus is_status(); the three deserve different backoff shapes and different alert thresholds.

pool_max_idle_per_host matters most for services with bursty concurrency. If your service normally sees 5 concurrent requests but occasionally spikes to 40, a pool cap of 32 means the spike will open new sockets on top of the reused ones, but the tail of that spike will still get keep-alive reuse on the way back down. A cap of 4 would force the spike to open fresh sockets on every excess request, defeating the point.

Try it: fa88f6c.

Step 4: End-to-end demo

The last commit wires everything together in main.rs:

#[tokio::main]
async fn main() {
    let cfg = PoolConfig::default();
    let client = build_tuned_client(&cfg);
    let target = std::env::var("TARGET_URL")
        .unwrap_or_else(|_| "http://127.0.0.1:9999/ping".to_string());

    let mut handles = Vec::with_capacity(8);
    for i in 0..8 {
        let c = client.clone();
        let u = target.clone();
        handles.push(tokio::spawn(async move {
            let res = c.get(&u).send().await;
            println!("[req {i}] status={:?}", res.map(|r| r.status()));
        }));
    }
    for h in handles {
        let _ = h.await;
    }
}

Eight concurrent workers, one tuned client, zero Arc wrapping in the user code. The pool inside client decides how many real sockets to open. With pool_max_idle_per_host: 32, you have plenty of headroom for the concurrency to reuse warm sockets after the first pass. Run the binary against a local mock (any tool that speaks HTTP will work; the integration tests use wiremock) and you can watch the elapsed time drop between the first and second run as the pool warms.

Try it: 8212e64.

Trade-offs to keep in mind

Some situations do not need this shape.

For a one-shot CLI tool that fires a single HTTP call and exits, Client::new() is fine. Pool tuning is about steady-state daemon behaviour, and there is no steady state in a program that runs for 200 ms. Same story for integration tests that hit a mock server: fresh clients per test module are simpler than shared state, and the pool complications are not worth it.

If you talk to hundreds of upstream hosts once each, pool_max_idle_per_host matters less than the connect timeout, because you rarely reuse anyway. Focus your builder settings on connect_timeout and timeout; the idle window will almost never fire. In that world you are optimising the tail of connection failures rather than the reuse rate.

And sometimes one tuned client is not enough. Reqwest bakes auth headers and TLS roots into the builder, so if two upstreams need different bearer tokens or different root stores, build two clients. Same story when the SLAs diverge: a 30-second timeout is wrong for a fast internal service, and a 2-second timeout is wrong for a slow document generator. Per-upstream clients, each with its own tuned pool, is a healthy pattern once you outgrow the single-client shape. Store them together in the application state (struct Clients { orders: Client, docs: Client, auth: Client }) so the wiring stays clear.

One anti-pattern worth flagging: do not build a fresh client every N minutes "to get a fresh pool". If your upstream is misbehaving in a way a pool reset would fix, the right lever is pool_idle_timeout (drop stale sockets sooner) or connect_timeout (fail fast on the ones that hang), not a scheduled client rebuild that throws away every warm connection along with the misbehaving one.

Repository

Full source at https://github.com/vytharion/reqwest-client-clone-pool-tuning.

  • Lesson 0 (scaffold) cf2ead5: Cargo manifest, empty lib + bin, README, LICENSE, .gitignore
  • Lesson 1 (naive baseline) 56a7b41: fetch_naive builds a fresh client per call
  • Lesson 2 (shared client) 918a160: build_shared_client + Client::clone() fan-out across four spawned tasks
  • Lesson 3 (tuned pool) fa88f6c: PoolConfig + build_tuned_client surfacing the five pool knobs
  • Lesson 4 (end-to-end demo) 8212e64: eight concurrent workers sharing one tuned client

Next steps

Clone the repo, run cargo test --release at the tip, and step through with git checkout <sha> to watch each lesson in isolation. Point the main.rs binary at your own upstream by setting TARGET_URL. If you want to see the pool behaviour live, wire in tracing and log the underlying hyper connection events; the connect-vs-reuse signal is the fastest way to confirm the fix is working before you invest in a synthetic benchmark. Once you trust the shape, the same pattern generalizes to sqlx::PgPool, redis::Client, and any other pool-backed resource that hides an Arc behind a friendly-looking constructor.