How to Use std

:net for Networking in Rust

std::net gives you blocking TcpListener, TcpStream, and UdpSocket for synchronous network I/O. Bind a port, accept connections, hand each off to a thread, and you have a working TCP server.

When you want to talk to the network without a framework

You've decided to write a tiny TCP server. Maybe it's a chat relay, maybe a toy HTTP responder, maybe a simple "hello, dump my IP" service. You don't want a whole web framework. You don't want async runtime gymnastics. You just want to bind a port, accept a connection, and exchange some bytes.

For that, Rust ships std::net in the standard library. It's a synchronous, blocking, thread-per-connection style API. Nothing fancy. Two main types do most of the work: TcpListener for the server side and TcpStream for an open connection in either direction. Plus their UDP siblings, UdpSocket, which we'll touch on briefly. If you've used Python's socket module or the BSD sockets API in C, this will feel familiar; the names are slightly Rustier.

A picture of what's happening

When a TCP server starts, it does three things in order. First, it asks the operating system to reserve a port and start listening on it. That's bind. Second, the OS keeps a queue of pending connections. Each time a client connects, it's appended to that queue until your code is ready to look at it. Third, your code calls accept, which pulls the next pending connection off the queue and hands you a stream you can read from and write to.

Think of it like a coffee shop with a takeout window. Binding the port is opening the shop. The queue is the line of customers. Accepting a connection is calling the next person up. Reading and writing on the stream is taking their order and handing them their coffee. When you're done, you close the window for that customer (drop the stream) and call the next one.

A minimal echo server

Let's build the smallest useful thing: a server that accepts connections and echoes back whatever the client sends.

use std::io::{Read, Write};
use std::net::TcpListener;

fn main() -> std::io::Result<()> {
    // Bind picks a specific local address and port. "127.0.0.1" means
    // localhost only; use "0.0.0.0" to accept connections from any interface.
    // Port 0 would let the OS pick a free port; we hardcode 7878 for clarity.
    let listener = TcpListener::bind("127.0.0.1:7878")?;
    println!("Listening on {}", listener.local_addr()?);

    // .incoming() returns an iterator that blocks until the next connection
    // arrives. Each item is a Result<TcpStream> because accept can fail
    // (e.g. file descriptor exhausted).
    for stream in listener.incoming() {
        let mut stream = stream?;
        let mut buf = [0u8; 1024];

        // read returns the number of bytes actually placed into the buffer.
        // 0 means "the client closed the connection" (EOF).
        let n = stream.read(&mut buf)?;

        // write_all keeps writing until either everything is sent or there's
        // an error. Plain .write may write fewer bytes than asked.
        stream.write_all(&buf[..n])?;
    }
    Ok(())
}

Run this in one terminal, then in another type:

$ nc 127.0.0.1 7878
hello
hello

What you typed comes back. The connection lasts for one read and one write because the loop drops stream immediately and goes back to waiting on incoming(). A real server would keep reading in a loop until EOF, and probably handle each connection on a thread.

Handling clients on threads

The single-threaded version above can only talk to one client at a time. Spawning a thread per connection is the simplest way to handle several at once with std::net:

use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;

// One function per connection. Owning the stream means we close it on return.
fn handle(mut stream: TcpStream) -> std::io::Result<()> {
    let mut buf = [0u8; 1024];
    loop {
        // Read until EOF. Once read returns 0, the client closed their end.
        let n = stream.read(&mut buf)?;
        if n == 0 { return Ok(()); }
        stream.write_all(&buf[..n])?;
    }
}

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("0.0.0.0:7878")?;
    for stream in listener.incoming() {
        let stream = stream?;
        // thread::spawn moves the stream into the new thread. The main loop
        // is free to accept the next connection immediately. Threads are
        // cheap on modern OSes; for a few hundred this is fine.
        thread::spawn(move || {
            if let Err(e) = handle(stream) {
                eprintln!("client error: {e}");
            }
        });
    }
    Ok(())
}

This pattern scales until you're handling thousands of concurrent connections. At that point, threads become expensive (memory and context switches) and the right move is async with Tokio. But up to a few hundred sockets, the synchronous thread-per-connection model is honest, simple, and hard to mess up.

A client, for completeness

Connecting from Rust looks symmetric:

use std::io::{Read, Write};
use std::net::TcpStream;

fn main() -> std::io::Result<()> {
    // connect performs the TCP handshake. It blocks until the server accepts
    // or the OS times out (typically tens of seconds).
    let mut stream = TcpStream::connect("127.0.0.1:7878")?;

    stream.write_all(b"ping\n")?;

    let mut response = String::new();
    // read_to_string reads until EOF. For an echo server that closes after
    // one round-trip, this works. For a long-lived protocol, read line-by-line
    // with BufReader.
    stream.read_to_string(&mut response)?;
    println!("server said: {response}");
    Ok(())
}

Timeouts so your code doesn't hang forever

Default TCP reads block indefinitely. That's almost never what you want in production. Set timeouts before you start reading:

use std::time::Duration;

stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;

If the timeout fires, your read returns an error with kind WouldBlock or TimedOut depending on platform. Handle both:

use std::io::ErrorKind;

match stream.read(&mut buf) {
    Ok(0) => break,             // graceful EOF
    Ok(n) => { /* use buf[..n] */ }
    Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => {
        eprintln!("client took too long");
        break;
    }
    Err(e) => return Err(e),
}

UDP, in one breath

For datagrams (DNS, game state updates, anything where dropping a packet beats waiting for retransmission), use UdpSocket:

use std::net::UdpSocket;

fn main() -> std::io::Result<()> {
    let socket = UdpSocket::bind("0.0.0.0:8000")?;
    let mut buf = [0u8; 1500]; // MTU-sized buffer
    loop {
        // recv_from gives you both the data and the sender's address.
        // No "connection" exists; every packet is independent.
        let (n, src) = socket.recv_from(&mut buf)?;
        socket.send_to(&buf[..n], src)?;
    }
}

There's a dedicated article on UDP servers and clients linked below if you want more depth.

Common pitfalls

Address already in use. If you stop and immediately restart your server, you may see:

Error: Os { code: 48, kind: AddrInUse, message: "Address already in use" }

The OS holds the port in TIME_WAIT for a minute or so after the previous process exits. The cure is to set SO_REUSEADDR. std::net doesn't expose that flag directly, so either wait it out, or use the socket2 crate, which gives you the underlying socket options.

Forgetting to flush. TcpStream::write may buffer output internally on some platforms. If you wrap it in a BufWriter, you definitely need to call flush() before the writer drops, or the last bytes might never leave. The compile won't warn you; the bytes just disappear into the void.

Holding the connection open by accident. If you keep the TcpStream value alive, the OS keeps the connection open, which means the client is waiting on you. Drop the stream (let it fall out of scope) when the conversation is finished.

When to reach for std::net vs alternatives

Use std::net when you're learning, prototyping, writing a small CLI, or building anything where blocking I/O is fine and connection counts are modest. Use tokio::net when you need to scale beyond a few hundred concurrent sockets, or when you're writing a service that already runs in an async runtime. The Tokio API mirrors std::net closely enough that porting later is mostly mechanical: the calls become .await, the types pick up Async prefixes, and everything else stays the same.

Where to go next