When you need a reliable pipe
You're building a multiplayer game lobby. Players need to connect, send their usernames, and wait for the match to start. Or you're writing a backend for a mobile app that streams sensor data back to your server. In both cases, you need a TCP server. You need something that sits there, waits for connections, and handles data reliably.
Rust's standard library gives you std::net::TcpListener to bind to a port and accept connections. It also gives you TcpStream to read and write data. The API is synchronous and blocking by default. This keeps the mental model simple: you ask for data, the program waits until it arrives, then you process it.
The listener and the stream
Think of a TcpListener as a reception desk. It occupies a specific address and port. Clients knock on that door. When a client connects, the listener hands you a TcpStream. The stream is the private conversation between your server and that client.
The listener doesn't handle the data. It only accepts the connection. Once you have the stream, the listener moves on to the next client. This separation is key. The listener manages the queue of incoming handshakes. The stream manages the flow of bytes.
TcpStream implements the Read and Write traits. These traits come from std::io. They provide methods like read, write, read_to_end, and write_all. Because these are traits, the same methods work on files, pipes, and network sockets. You write code against the trait, and it works everywhere.
Minimal server
Here is the smallest server that compiles and runs. It binds to localhost on port 7878 and prints a message for every connection.
use std::net::TcpListener;
fn main() {
// Bind reserves the port and creates the socket.
// Unwrap panics if the port is already in use or the address is invalid.
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
// incoming() returns an iterator over TcpStream results.
// The loop blocks until a client connects.
for stream in listener.incoming() {
// unwrap panics if the connection fails during acceptance.
let stream = stream.unwrap();
println!("Connection established!");
}
}
Run this code. Open another terminal and run nc 127.0.0.1 7878. You'll see "Connection established!" print in the server terminal. The server is now waiting for the next connection.
The bind call talks to the operating system. It marks the port as in-use. If you run two instances of this server, the second one will panic because the port is taken. In production code, you handle the error from bind instead of unwrapping. You might retry on a different port or exit with a clear message.
Convention aside: use 127.0.0.1 for local development. It binds only to the loopback interface. Other machines on your network can't reach it. Use 0.0.0.0 when you want to bind to all network interfaces. This is common in Docker containers or production deployments where the server must accept traffic from outside the host.
How the iterator drives the loop
The incoming method returns an iterator. This is a deliberate design choice. Iterators in Rust are lazy. They produce items on demand. The for loop calls next on the iterator. Under the hood, next calls accept on the socket. If no client is waiting, accept blocks the thread.
This blocking behavior is efficient. The thread sleeps at the OS level. It consumes zero CPU while waiting. When a connection arrives, the OS wakes the thread. You get the stream. The loop body runs. Then the loop asks for the next item, and the thread sleeps again.
You can compose the iterator with other iterator methods. This lets you add logic without writing boilerplate loops.
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
// Limit the server to 5 connections, then stop accepting.
for stream in listener.incoming().take(5) {
let stream = stream.unwrap();
println!("Handling one of five connections.");
}
println!("Server closed after 5 connections.");
}
The take(5) adapter wraps the iterator. After five items, next returns None. The loop ends. The listener is dropped. The port is released.
Treat incoming as a stream of work. The iterator pattern lets you compose filters and limits without writing boilerplate loops.
Reading and writing data
Accepting the connection is only the first step. You need to exchange data. TcpStream implements Read and Write. The read method fills a buffer with bytes. The write method sends bytes to the client.
Both methods return Result. read returns Ok(n) where n is the number of bytes read. It can return 0 if the client closed the connection. write returns Ok(n) where n is the number of bytes written. It might write fewer bytes than you requested.
TCP is a byte stream. It does not preserve message boundaries. If you send 10 bytes, the receiver might get 4 bytes, then 6 bytes. Or 10 bytes at once. Or 1 byte at a time. Your code must handle partial reads and writes.
use std::io::{Read, Write};
use std::net::TcpListener;
fn handle_client(mut stream: std::net::TcpStream) {
// Buffer to hold incoming bytes.
let mut buffer = [0; 1024];
// read blocks until data arrives or the connection closes.
// It may return fewer bytes than the buffer size.
match stream.read(&mut buffer) {
Ok(0) => {
// Zero bytes means the peer closed the connection.
println!("Client disconnected.");
}
Ok(n) => {
// Echo the data back.
// write_all ensures the entire slice is sent, even if it takes multiple syscalls.
let _ = stream.write_all(&buffer[..n]);
}
Err(e) => {
eprintln!("Error reading: {}", e);
}
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_client(stream);
}
}
The write_all method is safer than write. It loops internally until all bytes are sent. If you use write, you must check the return value and retry with the remaining bytes. write_all hides that complexity.
Convention aside: always check the return value of read. Never assume it fills the buffer. Network latency, packet loss, and OS buffering mean you might get partial data. If you're implementing a protocol, you need a framing layer. Read until you see a delimiter, or read a fixed header that tells you the message length.
If you try to write a string slice directly to the stream, the compiler rejects you with E0308 (mismatched types). Write::write expects &[u8], not &str. You need .as_bytes() to convert the string to bytes.
Handling multiple clients
The example above handles one client at a time. The for loop processes connections sequentially. While you're reading from client A, client B waits in the queue. This is fine for a prototype. It breaks under load.
To handle multiple clients, you need concurrency. The simplest approach is to spawn a thread for each connection. The thread takes ownership of the stream and runs the handler. The main loop moves on to the next connection immediately.
use std::io::{Read, Write};
use std::net::TcpListener;
fn handle_client(mut stream: std::net::TcpStream) {
let mut buffer = [0; 1024];
match stream.read(&mut buffer) {
Ok(0) => return,
Ok(n) => {
let _ = stream.write_all(&buffer[..n]);
}
Err(e) => eprintln!("Error: {}", e),
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
// Spawn a thread for each connection.
// The closure must move the stream because the thread may outlive the loop.
std::thread::spawn(move || {
handle_client(stream);
});
}
}
The move keyword is required here. The closure captures stream. The thread might run after the loop iteration ends. If the closure borrowed stream, the borrow would be invalid once the loop variable is dropped. move forces the closure to take ownership of stream. The stream is transferred into the thread's stack.
Threads isolate failures. If one client crashes your handler, the server keeps running for the others. The thread panics, but the main loop continues.
Convention aside: keep unsafe blocks out of network code unless you're implementing a custom buffer or calling FFI. The standard library handles the socket syscalls safely. You don't need unsafe to build a TCP server.
Pitfalls and compiler errors
Building a TCP server introduces specific failure modes. The compiler catches some. You must handle the rest.
If you try to use a TcpStream after moving it to a thread, the compiler rejects you with E0382 (use of moved value). The move closure consumes the stream. You can't access it in the main thread. This is a feature. It prevents data races.
If you forget to mark a variable as mutable before calling read or write, the compiler rejects you with E0596 (cannot borrow as mutable). read modifies the buffer. write might modify internal state. You need mut.
// This fails to compile.
let stream = TcpStream::connect("127.0.0.1:7878").unwrap();
let mut buffer = [0; 1024];
stream.read(&mut buffer); // Error: cannot borrow as mutable
Fix it by declaring mut stream.
let mut stream = TcpStream::connect("127.0.0.1:7878").unwrap();
stream.read(&mut buffer); // OK
Runtime errors are trickier. read can return Err if the connection drops unexpectedly. write can fail if the client stops reading. You must handle these errors. Ignoring them leads to silent data loss or hung threads.
Don't assume read gives you the whole message. TCP is a stream of bytes, not a stream of messages. You need to frame your data.
Decision: concurrency and libraries
Rust offers several ways to build TCP servers. The right choice depends on your load and complexity.
Use TcpListener with a simple loop when you're prototyping or building a single-client tool. The code is minimal. There's no concurrency overhead.
Use TcpListener with std::thread::spawn when you need to handle multiple clients simultaneously and don't want to pull in async dependencies. Threads are easy to reason about. Each connection runs in isolation.
Use an async runtime like Tokio when you expect thousands of concurrent connections or need non-blocking I/O for high-throughput services. Async tasks are lighter than threads. They multiplex many connections over a few OS threads.
Use TcpStream::set_nonblocking when you're building a custom event loop and want to manage concurrency manually. This is rare. You usually want a library to handle the event loop for you.
Pick the concurrency model that matches your load. Simple loops for simple tools. Async for scale.