How to Handle Network Timeouts in Rust

Web
Handle network timeouts in Rust by wrapping blocking I/O calls with `connect_timeout` or `set_read_timeout` using `std::time::Duration`.

When the network stops talking

You are building a health checker that pings a list of servers. One server has a broken cable. Your code calls connect. The thread hangs. The health checker stops reporting on the other nine servers. The dashboard goes stale. The user assumes your tool crashed.

Network I/O is unpredictable. Packets drop. Servers sleep. Firewalls drop connections silently. If your code waits indefinitely for a response, a single flaky endpoint can freeze your entire application. Timeouts are the mechanism that forces your code to give up and move on when the network fails to respond.

Rust provides timeouts in two distinct flavors. Synchronous timeouts block the current thread until the deadline passes. Asynchronous timeouts cancel the operation without blocking the thread. The API surface looks different for each, and mixing them up causes subtle bugs.

The concept: deadlines, not promises

Think of a timeout like ordering coffee at a busy shop. You place your order and wait. If the barista does not bring your drink within five minutes, you leave. You do not stand there until the shop closes. The five-minute mark is your deadline.

In Rust, a timeout does not guarantee the operation finishes. It guarantees the operation stops waiting after a certain duration. The operation might succeed early, fail with an error, or hit the timeout. Your code must handle all three outcomes.

Synchronous timeouts in std

The standard library handles timeouts for blocking I/O through methods on TcpStream. There are two separate concerns: connecting to a remote host and reading data from an established connection.

use std::net::{TcpStream, SocketAddr};
use std::time::Duration;

fn main() {
    // Parse the address. Use unwrap here for brevity; handle errors in production.
    let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();

    // connect_timeout returns a Result immediately if the timeout fires.
    // It does not block forever.
    let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5));

    match stream {
        Ok(mut s) => {
            // set_read_timeout configures subsequent read calls.
            // It expects an Option<Duration>. None disables the timeout.
            s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
            println!("Connected and configured.");
        }
        Err(e) => eprintln!("Connection failed or timed out: {}", e),
    }
}

The connect_timeout method attempts to establish a TCP connection. If the handshake does not complete within the specified duration, it returns an Err. The error kind is typically io::ErrorKind::TimedOut.

The set_read_timeout method configures the stream for future reads. It does not perform a read. It sets a limit on how long read or read_to_string will block waiting for data. If no data arrives within the limit, the read returns an error.

Convention aside

The community convention for set_read_timeout is to always pass Some(Duration). Passing None disables the timeout and reverts to blocking indefinitely. Explicit Some signals that you considered the timeout and chose a value. If you truly want no timeout, write None and add a comment explaining why blocking forever is safe in that context.

How connect and read timeouts differ

Connect timeouts and read timeouts solve different problems. A connect timeout protects against unreachable hosts or slow handshakes. A read timeout protects against a connected server that stops sending data.

You can have a successful connection but a timed-out read. The server accepts the connection, sends a greeting, and then goes silent. Your code connects instantly, but the first read blocks until the read timeout fires.

Conversely, you can have a timed-out connection but a fast read. The server is reachable but the handshake is slow due to network latency. The connect timeout fires before the connection establishes. You never get to the read stage.

Treat these as independent configuration knobs. Set a connect timeout based on how long you are willing to wait to reach the server. Set a read timeout based on how long you are willing to wait for a response once connected.

Don't assume a fast connection means fast data. Set both.

A realistic sync helper

Real code usually wraps timeout logic in a function that handles the error details. The standard library returns io::Error, which includes a kind field. Checking io::ErrorKind::TimedOut lets you distinguish between a timeout and other failures like connection refused.

use std::io::{self, Read};
use std::net::TcpStream;
use std::time::Duration;

fn fetch_status(addr: &str) -> Result<String, io::Error> {
    let socket_addr = addr.parse()?;

    // Connect with a 3-second deadline.
    let mut stream = TcpStream::connect_timeout(&socket_addr, Duration::from_secs(3))?;

    // Set a 3-second read timeout.
    stream.set_read_timeout(Some(Duration::from_secs(3)))?;

    let mut buffer = String::new();
    // read_to_string blocks until EOF or timeout.
    stream.read_to_string(&mut buffer)?;

    Ok(buffer)
}

The ? operator propagates errors. If connect_timeout fails, the function returns early. If set_read_timeout fails, the function returns early. If read_to_string hits the timeout, it returns an error with kind TimedOut, and the function returns that error.

Callers can inspect the error kind to decide whether to retry. A timeout might indicate a transient network glitch. A connection refused error usually means the server is down or the port is wrong.

Pitfall: Read timeouts reset on every call

The read timeout applies to a single read operation, not the entire session. If you call read in a loop, each call gets the full timeout duration.

You set a 5-second read timeout. You read 100 bytes. The timeout resets. You read 100 more bytes. The timeout resets. If the server sends data slowly, you might timeout on the very last byte, even though the connection has been open for an hour. The timeout measures the gap between bytes, not the total session time.

If you need a deadline for the entire operation, you must track the elapsed time yourself or use a higher-level abstraction. The stream timeout is a per-call limit.

Trust the per-call semantics. If you need a global deadline, implement it at the application level.

Compiler error: E0308 on set_read_timeout

If you forget to wrap the duration in Some, the compiler rejects the code with E0308 (mismatched types). The function signature expects Option<Duration>.

// This fails to compile.
stream.set_read_timeout(Duration::from_secs(5));

The error message points out the type mismatch. Wrap the duration in Some to fix it.

// This compiles.
stream.set_read_timeout(Some(Duration::from_secs(5)));

Async timeouts with Tokio

Synchronous timeouts block the thread. In an async application, blocking the thread prevents the runtime from executing other tasks. If you block a Tokio worker thread, you reduce the concurrency of your entire application.

Async code uses tokio::time::timeout to enforce deadlines without blocking. This function takes a duration and a future. It polls the future until it completes or the duration elapses. If the duration elapses, the future is cancelled and the timeout returns an error.

use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};

#[tokio::main]
async fn main() {
    // timeout wraps the future. It returns a Result<Result<T, E>, Elapsed>.
    let connect_result = timeout(
        Duration::from_secs(5),
        TcpStream::connect("127.0.0.1:8080")
    ).await;

    match connect_result {
        // The future completed successfully within the timeout.
        Ok(Ok(stream)) => println!("Connected: {:?}", stream.peer_addr().unwrap()),
        // The future completed with an error within the timeout.
        Ok(Err(e)) => eprintln!("Connection error: {}", e),
        // The timeout fired before the future completed.
        Err(_) => eprintln!("Connection timed out"),
    }
}

The timeout function returns a nested result. The outer result indicates whether the timeout fired. The inner result indicates whether the operation succeeded or failed.

This structure forces you to handle timeouts explicitly. You cannot accidentally treat a timeout as a normal error. You must match on the outer result first.

Convention aside

The double-result pattern Result<Result<T, E>, Elapsed> is a deliberate design choice. It prevents code from swallowing timeouts. If timeout returned a flat result, you might write match result { Ok(v) => ..., Err(e) => ... } and treat a timeout the same as a connection error. The nested structure makes the distinction visible in the type system.

Write your matches to handle the outer result first. This keeps your error handling logic clear and prevents accidental conflation of timeout and failure.

Pitfall: Cancellation safety

Async timeouts cancel the future when the deadline passes. Cancellation is not the same as returning an error. The future stops executing. Any resources held by the future might not be cleaned up unless the future implements cancellation safety.

Most Tokio I/O types are cancellation-safe. If you cancel a TcpStream::connect, the connection attempt is aborted and resources are released. If you cancel a custom future that holds a lock or a file handle, you must ensure the future drops those resources when cancelled.

If you wrap a blocking call inside timeout, you create a hazard. The timeout cancels the wrapper, but the blocking call continues running on the thread. This defeats the purpose of async and can lead to resource leaks.

Never wrap blocking I/O in tokio::time::timeout. Use spawn_blocking for blocking code, or switch to async I/O.

Compiler error: E0277 on non-futures

If you try to pass a non-async function to timeout, the compiler rejects it with E0277 (trait bound not satisfied). timeout requires the second argument to implement Future.

// This fails to compile.
let _ = timeout(Duration::from_secs(5), blocking_function()).await;

The error message indicates that the return type does not implement Future. You cannot await a blocking function. Convert the function to async or use spawn_blocking.

Decision matrix

Use TcpStream::connect_timeout when you are writing synchronous code and need to limit how long a connection attempt can block.

Use set_read_timeout when you need to cap the duration of individual read operations in blocking I/O.

Use tokio::time::timeout when your application runs on the Tokio runtime and you want to cancel an entire async operation if it drags on too long.

Use tokio::net::TcpStream::connect_timeout when you are doing async networking and need a connection deadline without spawning a separate task.

Pick the tool that matches your runtime. Mixing sync timeouts in async code will block the event loop and kill your throughput.

Where to go next