Bind, accept, handle
You want a program that sits in the background, waits for a knock on a specific door, and talks to whoever shows up. Maybe it's a backend for a multiplayer game, a simple chat relay, or just a way to pipe data between two scripts. You don't need a heavy web framework for this. You need a TCP server. Rust gives you the raw tools in the standard library, and the path from "hello world" to "handling multiple clients" is short, but the details matter.
Think of a TCP server like a reception desk at a busy office. The desk has a phone number (the port). When someone calls, the phone rings. The receptionist picks up (accepts the connection) and talks to the caller. While the receptionist is on the phone, the line is busy. If a second person calls, they either get a busy signal or wait in a queue. The receptionist can only handle one conversation at a time unless they have a way to hand off calls to other staff members. That's the core model: bind to a port, accept connections, handle them one by one, and figure out how to juggle multiple callers.
The smallest working server
Here's the smallest echo server. It binds to a port, waits for a client, reads bytes, and sends them right back.
use std::io::{Read, Write};
use std::net::TcpListener;
fn main() -> std::io::Result<()> {
// bind() claims the port from the OS. Returns Err if the port is taken.
// 127.0.0.1 restricts access to the local machine only.
let listener = TcpListener::bind("127.0.0.1:7878")?;
println!("Listening on 127.0.0.1:7878");
// incoming() returns an iterator that blocks until a connection arrives.
// The loop pauses here; no CPU spinning occurs.
for stream in listener.incoming() {
let mut stream = stream?;
// Buffer for incoming data. 512 bytes is enough for a quick test.
let mut buf = [0u8; 512];
// read() fills the buffer and returns the number of bytes read.
let n = stream.read(&mut buf)?;
// write_all() sends the exact bytes back to the client.
stream.write_all(&buf[..n])?;
}
Ok(())
}
Run nc 127.0.0.1 7878 in another terminal. Type a message and hit Enter. If your server echoes it back, the plumbing works. If it hangs, check your firewall or try a different port.
What happens under the hood
The bind call asks the operating system to reserve a port. If another process already owns that port, bind returns an error. The ? operator propagates that error up to main, which prints a message and exits. This is the standard Rust pattern for error handling: fail fast, print the error, stop.
incoming creates an iterator. Each call to the iterator's next method blocks the current thread. The thread sleeps until the kernel detects a new TCP handshake completion. When a connection arrives, the iterator yields a TcpStream. This stream is a bidirectional pipe. You can read from it and write to it.
read is a low-level function. It copies bytes from the kernel buffer into your buf. It might return fewer bytes than you asked for. If the client sends 10 bytes but you asked for 512, read returns Ok(10). You must check the return value n and only process buf[..n]. If you ignore n, you'll process garbage data from previous reads or uninitialized memory.
The community convention is to never discard the result of I/O operations. Writing let _ = stream.read(&mut buf); signals that you don't care about the result. Never do this for I/O. Always check the result or use ?. If you accidentally drop the result, you might loop forever on a disconnected socket or process stale data.
When you call bind, the OS creates a queue for pending connections. If connections arrive faster than you can accept them, they pile up in this queue. Once the queue fills, the OS rejects new connections. The default size is usually 128. For a server that does heavy work per connection, you might need to increase this, but TcpListener::bind doesn't expose the backlog parameter directly. You'd need socket2 for that.
Handling multiple clients
An echo server is a toy. Real servers parse commands. You also need to handle multiple clients. If client A connects and sits silent, the loop blocks, and client B can never connect. You need concurrency. The simplest approach is spawning a thread per connection.
Here's a handler that reads lines and responds to commands. It uses BufReader for efficient line-based reading and try_clone to keep write access.
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
/// Handles a single client connection.
/// Reads lines, processes commands, and writes responses.
fn handle_client(stream: TcpStream) -> std::io::Result<()> {
// BufReader adds buffering so we can read line-by-line efficiently.
// It takes ownership of the stream, so we can't write anymore.
let reader = BufReader::new(stream);
// try_clone() creates a second handle to the same socket.
// This allows us to write while the reader is active.
let mut writer = stream.try_clone()?;
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
match trimmed {
"ping" => writeln!(writer, "pong")?,
"quit" => {
writeln!(writer, "bye")?;
return Ok(());
}
other => writeln!(writer, "unknown: {other}")?,
}
}
Ok(())
}
The main loop spawns a thread for each connection. The move keyword transfers ownership of the stream into the closure.
use std::net::TcpListener;
use std::thread;
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:7878")?;
for stream in listener.incoming() {
let stream = stream?;
// Spawn a thread for each connection.
// move || transfers ownership of stream into the closure.
thread::spawn(move || {
// handle_client returns Result. We ignore errors here
// to keep the server running even if one client crashes.
let _ = handle_client(stream);
});
}
Ok(())
}
BufReader consumes the stream. If you try to use stream after passing it to BufReader, the compiler rejects you with E0382 (use of moved value). The stream is gone. You must clone it first. try_clone duplicates the file descriptor, giving you two independent handles to the same connection. One can read while the other writes.
The convention for thread-per-connection servers is to drop the result of the handler. Writing let _ = handle_client(stream); discards the error. This is intentional. If a client disconnects abruptly, handle_client returns an error. Logging every disconnect error clutters the logs. Dropping the result signals "I know this might fail, and I'm okay with it."
Threads are cheap enough for most small services. If your server starts swapping or the OS complains about too many processes, it's time to switch to async.
Common pitfalls
Address already in use after a crash. When you kill the server, the OS keeps the port in TIME_WAIT state for up to two minutes. Restarting immediately fails with os error 48 or EADDRINUSE. Either wait, or set SO_REUSEADDR via socket2 for development. In production, this is usually fine because your process manager waits long enough.
Forgetting to handle a partial read. read is allowed to return fewer bytes than the buffer size, even when more data is on the way. Always check the return value or use read_exact if you need a fixed amount. Assuming you got a full buffer is a bug waiting to happen.
Forgetting the connection has two ends. A TcpStream is bidirectional, but a BufReader consumes ownership. If you need to both read and write, you typically try_clone the stream so you have one handle for reads and one for writes. If you skip the clone, you lose write access and the client waits forever.
Single-threaded server hangs the whole thing. As written, the minimal echo example serializes everything. If a client opens a connection and never sends anything, the server can't accept new connections. Either spawn threads or use timeouts. A silent client can hold up the entire server if you're not careful.
When to reach for what
Use std::net::TcpListener with threads for simple services, internal tools, or when you expect fewer than a few hundred concurrent connections. The code is straightforward, and the standard library handles the OS details.
Use tokio or async-std for high-concurrency servers, chat backends, or when you need to manage thousands of long-lived connections. The async model uses less memory per connection and scales better under load.
Use hyper, axum, or actix-web for HTTP services. Don't write raw TCP handlers for HTTP. These crates manage framing, headers, and connection pooling for you.
Use socket2 when you need fine-grained control over socket options like SO_REUSEADDR, TCP_NODELAY, or binding to specific interfaces with advanced flags.
Match the concurrency model to the load. Don't over-engineer a script with async, and don't under-engineer a production service with threads.