Rust Networking: TCP Servers and HTTP Clients
Almost every program you use talks to another computer: a chat app, a game, a weather widget. Under the hood, they open a connection, send some bytes, and read the answer. Rust is very good at this. It’s fast, it uses little memory, and the compiler catches many of the bugs that make network code crash at 3 a.m.
On this page you’ll write a small TCP server and client, serve several clients at once (first with threads, then with async tokio), and then call real HTTP APIs with reqwest. The TCP examples run right here in the Rust Playground. The reqwest examples need the internet, so you run those on your own computer with cargo run. Every one of them was built and run, and the output shown is real.
What you can build
Section titled “What you can build”- Network services and proxies. Cloudflare built Pingora, its open-source framework for proxies, in Rust. The Linkerd service mesh also uses a Rust proxy.
- Web APIs and back ends, with axum on top of tokio. See Project: Build a Web API.
- API clients and tools: a command-line tool that calls GitHub, a bot, a script that checks if your sites are up.
- Custom protocols: a game server, a chat server, a device that sends sensor readings over UDP.
Where it’s harder: async Rust has a real learning curve, and a first build with networking crates takes a minute or two. For a 10-line script that calls one API, Python may be quicker to write. Rust pays off when the program must be fast, run for months, or handle many connections.
Which crate?
Section titled “Which crate?”| Crate | What it’s for | Style | Pick it when |
|---|---|---|---|
std::net |
TCP and UDP sockets, built into Rust | blocking | Learning, small tools, a few connections. No dependencies. |
| tokio | Async runtime with async TCP, UDP, timers | async | Many connections at once. Most network crates build on it. |
| reqwest | Friendly HTTP client (JSON, timeouts, TLS) | async (blocking optional) | You call web APIs. The usual first choice. |
| ureq | Small, simple HTTP client | blocking | You don’t want async or tokio at all. |
| hyper | Low-level HTTP engine under reqwest and axum | async | You’re building a framework or need full control. |
| axum | Web framework for HTTP servers | async | You’re writing a web API. See the axum project. |
All of these are mature and widely used. A good path: learn the ideas with std::net, then move to tokio, reqwest, and axum.
A TCP server and client in one program
Section titled “A TCP server and client in one program”TCP gives you a stream of bytes between two programs, like a phone call. The server listens on an address and a port. The client connects to it. Then both sides can read and write.
Normally the server and the client are two programs. Here we put both in one program, so it runs in the Playground: the server runs in a thread, and main is the client.
use std::io::{BufRead, BufReader, Write};use std::net::{TcpListener, TcpStream};use std::thread;
fn main() -> std::io::Result<()> { // Port 0 means "any free port". Then we ask which one we got. let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
// The server: accept one client, read one line, answer with one line. let server = thread::spawn(move || -> std::io::Result<()> { let (stream, _) = listener.accept()?; let mut name = String::new(); BufReader::new(&stream).read_line(&mut name)?; writeln!(&stream, "Hello, {}!", name.trim())?; Ok(()) });
// The client: connect, send one line, read the answer. let stream = TcpStream::connect(addr)?; writeln!(&stream, "Ferris")?; let mut reply = String::new(); BufReader::new(&stream).read_line(&mut reply)?; print!("client got: {reply}");
server.join().unwrap()?; println!("server finished"); Ok(())}Output:
client got: Hello, Ferris!server finishedA few things to notice:
127.0.0.1means “this computer only”. Use0.0.0.0when other machines should reach your server.- TCP has no idea where one message ends. We use a simple rule: one message = one line.
writeln!works on a stream likeprintln!works on the screen: it adds the\n.read_linereads up to it. - Every network call can fail, so each one returns a
Resultand we use?. &streamworks for reading and writing, because&TcpStreamimplements bothReadandWrite.
Many clients with threads
Section titled “Many clients with threads”A real server must not make client number 2 wait until client number 1 is done. The classic answer: one thread per connection. The loop accepts a client, hands it to a new thread, and goes straight back to accept.
This server counts the words in every line it receives. Three clients connect at the same time:
use std::io::{BufRead, BufReader, Write};use std::net::{SocketAddr, TcpListener, TcpStream};use std::thread;
// Server side: reply to every line with its word count.fn handle_client(stream: TcpStream) -> std::io::Result<()> { for line in BufReader::new(&stream).lines() { let words = line?.split_whitespace().count(); writeln!(&stream, "{words} words")?; } Ok(()) // the client closed the connection}
// Client side: send one line, return the answer.fn ask(addr: SocketAddr, msg: &str) -> std::io::Result<String> { let stream = TcpStream::connect(addr)?; writeln!(&stream, "{msg}")?; let mut reply = String::new(); BufReader::new(&stream).read_line(&mut reply)?; Ok(format!("{msg:?} -> {}", reply.trim()))}
fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
thread::spawn(move || { for stream in listener.incoming() { let stream = stream.unwrap(); thread::spawn(move || handle_client(stream)); // one thread per client } });
// Three clients at the same time, each in its own thread. let clients = ["hello there", "rust is fun to learn", "see you soon"] .map(|msg| thread::spawn(move || ask(addr, msg))); // Wait for them in order, so the output order is always the same. for c in clients { println!("{}", c.join().unwrap()?); } Ok(())}Output:
"hello there" -> 2 words"rust is fun to learn" -> 5 words"see you soon" -> 3 wordsWhen a client drops its TcpStream, the connection closes, lines() ends, and that client’s thread finishes. The accept loop never ends, which is normal for a server. Here the program simply stops when main returns. This pattern is simple and works well for tens or hundreds of clients. With thousands, all those threads cost a lot of memory. That’s where async comes in.
The same server with tokio
Section titled “The same server with tokio”With tokio, each client is a task instead of a thread. A task is tiny, so one server can hold many thousands of connections. Compare it with the threaded version above: std::net becomes tokio::net, thread::spawn becomes tokio::spawn, and you add .await wherever the program waits for the network.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};use tokio::net::{TcpListener, TcpStream};
// Same word-count server, but each client is a cheap task, not a thread.async fn handle_client(stream: TcpStream) -> std::io::Result<()> { let (read_half, mut write_half) = stream.into_split(); let mut lines = BufReader::new(read_half).lines(); while let Some(line) = lines.next_line().await? { let words = line.split_whitespace().count(); write_half.write_all(format!("{words} words\n").as_bytes()).await?; } Ok(())}
async fn ask(addr: std::net::SocketAddr, msg: &str) -> std::io::Result<String> { let mut stream = TcpStream::connect(addr).await?; stream.write_all(format!("{msg}\n").as_bytes()).await?; let mut reply = String::new(); BufReader::new(stream).read_line(&mut reply).await?; Ok(format!("{msg:?} -> {}", reply.trim()))}
#[tokio::main]async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?;
tokio::spawn(async move { loop { let (stream, _) = listener.accept().await.unwrap(); tokio::spawn(handle_client(stream)); // one task per client } });
// Start three clients at once, then wait for each in order. let clients = ["hello there", "rust is fun to learn", "see you soon"] .map(|msg| tokio::spawn(ask(addr, msg))); for c in clients { println!("{}", c.await.unwrap()?); } Ok(())}Output:
"hello there" -> 2 words"rust is fun to learn" -> 5 words"see you soon" -> 3 wordsinto_split() gives you a read half and a write half, so the reading and the writing can live in different variables. As before, the accept loop runs forever. When main returns, tokio stops all tasks.
UDP in one minute
Section titled “UDP in one minute”UDP has no connection. You send single messages (datagrams) to an address, and each one arrives whole or not at all. There’s no guarantee of order or delivery. It’s used for games, video calls, DNS, and sensors, where speed matters more than a lost packet. In std::net it’s one type, UdpSocket:
let socket = UdpSocket::bind("127.0.0.1:0")?;socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // one message, sent to one address
let mut buf = [0u8; 1500];let (n, from) = socket.recv_from(&mut buf)?; // waits for one message from anyonetokio::net::UdpSocket works the same way, with .await.
Timeouts and network errors
Section titled “Timeouts and network errors”On a network, things go wrong all the time: nobody is listening, the Wi-Fi drops, a server hangs. Two rules keep your program healthy: handle every error, and never wait forever. By default, a read waits forever. set_read_timeout fixes that:
use std::io::{ErrorKind, Read};use std::net::{TcpListener, TcpStream};use std::time::Duration;
fn main() -> std::io::Result<()> { // 1. Nobody is listening: connecting fails right away. let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // dropped = closed match TcpStream::connect(free_port) { Ok(_) => println!("connected?!"), Err(e) => println!("connect failed: {:?}", e.kind()), }
// 2. A server that accepts but never answers. let silent = TcpListener::bind("127.0.0.1:0")?; let mut stream = TcpStream::connect(silent.local_addr()?)?; stream.set_read_timeout(Some(Duration::from_millis(200)))?;
let mut buf = [0u8; 64]; match stream.read(&mut buf) { Ok(n) => println!("read {n} bytes"), // Linux reports WouldBlock, Windows reports TimedOut. Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { println!("read timed out after 200 ms") } Err(e) => println!("other error: {e}"), } Ok(())}Output:
connect failed: ConnectionRefusedread timed out after 200 mse.kind() gives you an ErrorKind you can match on. There’s also TcpStream::connect_timeout for slow connects. In tokio, wrap any future in tokio::time::timeout:
let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;// Err(_) = it took too long; Ok(inner) = the read finished (inner is its own Result)HTTP and JSON with reqwest
Section titled “HTTP and JSON with reqwest”HTTP is the language of the web, and it runs on top of TCP. You could write HTTP by hand over a TcpStream, but a client crate handles the details for you: HTTPS, redirects, headers, compression. These examples need internet access and the reqwest crate, so they don’t run in the Playground. Run them on your computer:
cargo new api-clientcd api-clientcargo add reqwest --features jsoncargo add tokio --features fullcargo add serde --features derive[dependencies]reqwest = { version = "0.13", features = ["json"] }serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }We’ll use JSONPlaceholder, a free fake API made for practice. The first build downloads and compiles quite a few crates, so give it a minute.
GET and POST JSON
Section titled “GET and POST JSON”reqwest::get(url).await?.text().await? gives you the body as a String. But for JSON, there’s something better: with the json feature, reqwest uses serde to turn the response straight into your struct, and to turn your struct into a request body:
use serde::{Deserialize, Serialize};
// Only the fields you list are read. Extra JSON fields (like "userId") are ignored.#[derive(Deserialize)]struct Todo { id: u32, title: String, completed: bool,}
#[derive(Serialize)]struct NewTodo { title: String, completed: bool,}
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { let client = reqwest::Client::new();
// GET: JSON -> your struct let todo: Todo = client .get("https://jsonplaceholder.typicode.com/todos/1") .send() .await? .json() .await?; println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST: your struct -> JSON (this test API answers, but doesn't really save it) let new = NewTodo { title: "Learn reqwest".to_string(), completed: false }; let response = client .post("https://jsonplaceholder.typicode.com/todos") .json(&new) .send() .await?; println!("status: {}", response.status()); println!("{}", response.text().await?); Ok(())}#1: delectus aut autem (done: false)status: 201 Created{ "title": "Learn reqwest", "completed": false, "id": 201}Create one Client and reuse it. It keeps connections open and reuses them, which makes later requests faster. .json(&new) also sets the Content-Type: application/json header for you.
Don’t want async? ureq is a small blocking client: ureq::get(url).call()?.body_mut().read_to_string()? gives you the body as a String, no tokio needed.
Timeouts and errors with reqwest
Section titled “Timeouts and errors with reqwest”reqwest has no timeout by default, so a stuck server can make your program wait forever. Set one on the client. Also note: a 404 Not Found is not an error for reqwest. The request worked, the answer was just “no”. Call error_for_status() to turn 4xx and 5xx answers into errors:
use std::time::Duration;
// Turns 4xx/5xx answers into errors too, not only network problems.async fn fetch(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> { let response = client.get(url).send().await?.error_for_status()?; response.text().await}
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { // Without a timeout, a request to a stuck server can wait forever. let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build()?;
// A local server that accepts connections but never answers. let silent = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let stuck = format!("http://{}/", silent.local_addr().unwrap());
let urls = [ "https://jsonplaceholder.typicode.com/todos/1", "https://jsonplaceholder.typicode.com/todos/999999", "http://127.0.0.1:1/", &stuck, ]; for url in urls { match fetch(&client, url).await { Ok(body) => println!("ok, {} bytes", body.len()), Err(e) if e.is_status() => println!("server said {}", e.status().unwrap()), Err(e) if e.is_connect() => println!("could not connect: {e}"), Err(e) if e.is_timeout() => println!("gave up after 2 seconds"), Err(e) => println!("other error: {e}"), } } Ok(())}ok, 83 bytesserver said 404 Not Foundcould not connect: error sending request for url (http://127.0.0.1:1/)gave up after 2 secondsis_status(), is_connect() and is_timeout() tell you what kind of failure happened, so you can decide what to do: retry later, show a message, or give up. In a bigger program, anyhow makes it easy to add context to these errors.
Common mistakes
Section titled “Common mistakes”- Both sides wait for each other. The client calls
read_line, but the server is also reading, because the client forgot the\n. Nobody ever sends, and the program hangs. Agree on where a message ends (a newline is the easiest rule). - Reading “until the end” too early.
read_to_stringwaits until the other side closes the connection. On a connection that stays open, it never returns. Read line by line instead. - Blocking inside async code. Using
std::netorstd::thread::sleepinside a tokio task blocks a whole worker thread. In async code, usetokio::netandtokio::time::sleep. - Forgetting timeouts. Your tests pass on a fast local network. Then one slow server freezes everything. Set timeouts from day one.
Practice
Section titled “Practice”1. A calculator server
Section titled “1. A calculator server”Write a server that reads lines like add 2 3 or mul 4 5 and answers with the result. For a bad line, answer with a short error: ... message. Test it from main with one connection and four questions. Hint: split the line with split_whitespace(), parse the numbers with parse::<i64>(), and reply with writeln!.
Show solution
use std::io::{BufRead, BufReader, Write};use std::net::{TcpListener, TcpStream};use std::thread;
fn calculate(line: &str) -> String { let parts: Vec<&str> = line.split_whitespace().collect(); let [op, a, b] = parts[..] else { return "error: use <op> <a> <b>".to_string(); }; let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else { return "error: not a number".to_string(); }; match op { "add" => (a + b).to_string(), "mul" => (a * b).to_string(), _ => format!("error: unknown op {op}"), }}
fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
let server = thread::spawn(move || -> std::io::Result<()> { let (stream, _) = listener.accept()?; for line in BufReader::new(&stream).lines() { writeln!(&stream, "{}", calculate(&line?))?; } Ok(()) });
let stream = TcpStream::connect(addr)?; let mut replies = BufReader::new(&stream); for question in ["add 2 3", "mul 4 5", "add 2 x", "div 8 2"] { writeln!(&stream, "{question}")?; let mut answer = String::new(); replies.read_line(&mut answer)?; println!("{question} = {}", answer.trim()); } drop(stream); // closing the connection ends the server's loop server.join().unwrap()}Output:
add 2 3 = 5mul 4 5 = 20add 2 x = error: not a numberdiv 8 2 = error: unknown op divKeeping the logic in a plain calculate function means you can test it without any network at all. The let ... else lines return early when the input doesn’t have the right shape.
2. Count finished to-dos (on your machine)
Section titled “2. Count finished to-dos (on your machine)”Using reqwest, fetch https://jsonplaceholder.typicode.com/todos?userId=1. It returns a JSON array of to-dos. Print how many there are, how many are completed, and the title of the first one that isn’t. Hint: deserialize into Vec<Todo>, then use iterator methods like filter and find.
Show solution
use serde::Deserialize;
#[derive(Deserialize)]struct Todo { title: String, completed: bool,}
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { let todos: Vec<Todo> = reqwest::get("https://jsonplaceholder.typicode.com/todos?userId=1") .await? .error_for_status()? .json() .await?;
let done = todos.iter().filter(|t| t.completed).count(); println!("user 1 has {} to-dos, {done} completed", todos.len());
let first_open = todos.iter().find(|t| !t.completed).unwrap(); println!("first open one: {}", first_open.title); Ok(())}user 1 has 20 to-dos, 11 completedfirst open one: delectus aut autemA JSON array maps to a Vec, and serde fills in each element for you. Your struct only lists the fields you need.
std::netgives you blocking TCP (TcpListener,TcpStream) and UDP (UdpSocket) with no extra crates.- Bind to port
0to get any free port, and read it back withlocal_addr(). - TCP is a byte stream: agree on where a message ends, for example one message per line.
- Serve many clients with one thread per connection, or with tokio tasks when you need thousands.
- reqwest (async) or ureq (blocking) call HTTP APIs, and serde turns JSON into your structs.
- Always set timeouts, and use
error_for_status()so a 404 doesn’t look like success.
Quick check
Section titled “Quick check”Quick check
1. What does binding a TcpListener to 127.0.0.1:0 do?
2. Why do the examples on this page send a \n at the end of every message?
3. reqwest gets a 404 Not Found answer. What does send().await return?
4. Your tokio server handles each client with std::net and std::thread::sleep. What's the problem?
Score: 0 / 4
Read more
Section titled “Read more”- std::net — Rust standard library:
TcpListener,TcpStream,UdpSocketand addresses, with examples. - Building a Single-Threaded Web Server — The Rust Book: the Book’s final project builds an HTTP server on
std::net, then adds a thread pool. - Tokio tutorial: async TCP step by step, from spawning tasks to framing messages.
- reqwest — docs.rs: the client, requests, JSON, timeouts and the
blockingmodule. - Web clients — Rust Cookbook: short recipes for HTTP requests, APIs and downloads.