Skip to content
portfolio/howtos/Learning Rust/

Chapter 05

Your First TCP Listener

Time to write real networking code. We'll open a socket, accept connections, read bytes off the wire, and send back an HTTP response - all with Rust's standard library.

Networking Basics: TCP, Sockets, Ports

Before we write code, a quick refresher on what's actually happening when a browser talks to a server:

  1. Your server binds to an address and port - say 127.0.0.1:8080. This tells the operating system "send me any TCP connections that arrive on port 8080."
  2. A client (browser, curl, another program) opens a TCP connection to that address. TCP guarantees that bytes arrive in order and without corruption.
  3. The OS hands your server a socket - a bidirectional byte stream. You can read bytes the client sent and write bytes back.
  4. HTTP is a text protocol layered on top of TCP. The client sends a request as text, the server sends a response as text. The TCP connection is just the pipe.
Browser                          Server
   │                                │
   │  TCP connect to 127.0.0.1:8080 │
   │───────────────────────────────▶│
   │                                │ accept() → TcpStream
   │  "GET / HTTP/1.1\r\n..."       │
   │───────────────────────────────▶│
   │                                │ read() → raw bytes
   │                                │
   │  "HTTP/1.1 200 OK\r\n..."      │
   │◀───────────────────────────────│
   │                                │ write() → send response
   │  connection closed             │
   │◀──────────────────────────────▶│

Rust's standard library gives us everything we need in the std::net module. No external crates required.

std::net::TcpListener - Binding and Accepting

Let's write the simplest possible server. It binds to a port, waits for a connection, and prints that someone connected:

src/main.rs
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
    println!("Listening on http://127.0.0.1:8080");

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                println!("New connection from {}", stream.peer_addr().unwrap());
            }
            Err(e) => {
                eprintln!("Connection failed: {}", e);
            }
        }
    }
}

Let's break this down line by line:

  • TcpListener::bind("127.0.0.1:8080") asks the OS to reserve port 8080. It returns a Result - binding can fail if the port is already in use. We .unwrap() for now (crash on failure).
  • listener.incoming() returns an iterator that yields a new Result<TcpStream> for each incoming connection. The loop blocks, waiting for the next connection.
  • stream.peer_addr() gives us the client's IP and port.

Run it with cargo run, then open another terminal and test with:

curl http://127.0.0.1:8080

You'll see "New connection from 127.0.0.1:XXXXX" printed in the server terminal. Curl will hang and eventually time out - because we're not sending any response yet. Let's fix that.

Note
If you get "Address already in use," another process is on port 8080. Either stop it or change the port: TcpListener::bind("127.0.0.1:3000").

Reading Raw Bytes from a TcpStream

A TcpStream is a raw byte stream. To read from it, we use the Read trait from the standard library. We provide a buffer - a fixed-size array of bytes - and the OS fills it with whatever the client sent:

src/main.rs
use std::io::Read;
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
    println!("Listening on http://127.0.0.1:8080");

    for stream in listener.incoming() {
        match stream {
            Ok(mut stream) => {
                // Create a 1KB buffer to hold the incoming data
                let mut buffer = [0u8; 1024];

                // Read bytes from the stream into the buffer
                match stream.read(&mut buffer) {
                    Ok(bytes_read) => {
                        // Convert the raw bytes to a string for display
                        let request = String::from_utf8_lossy(&buffer[..bytes_read]);
                        println!("Received {} bytes:\n{}", bytes_read, request);
                    }
                    Err(e) => {
                        eprintln!("Failed to read: {}", e);
                    }
                }
            }
            Err(e) => {
                eprintln!("Connection failed: {}", e);
            }
        }
    }
}

A few things to notice:

  • let mut buffer = [0u8; 1024] creates an array of 1024 zero bytes on the stack. The type [0u8; 1024] means "an array of u8 with 1024 elements, all initialized to 0."
  • stream.read(&mut buffer) fills the buffer and returns Result<usize> - the number of bytes actually read. A real HTTP request is usually much less than 1KB.
  • String::from_utf8_lossy converts bytes to a string, replacing invalid UTF-8 with . We slice with &buffer[..bytes_read] to only look at the bytes that were actually filled.
  • The stream must be mut because reading consumes bytes from it - it changes the stream's internal state.

Run the server and hit it with curl again. Now you'll see the raw HTTP request:

Received 78 bytes:
GET / HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: curl/8.7.1
Accept: */*

That's a real HTTP request, straight from the wire. Each line is separated by \r\n (carriage return + newline), and the headers end with a blank line (\r\n\r\n). We'll parse this properly in Chapter 6. For now, we just need to read it.

Writing a Response Back to the Client

Now let's send something back. An HTTP response has the same structure we built in earlier chapters: a status line, headers, a blank line, and the body. We write it to the stream using the Write trait:

src/main.rs
use std::io::{Read, Write};
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
    println!("Listening on http://127.0.0.1:8080");

    for stream in listener.incoming() {
        match stream {
            Ok(mut stream) => {
                let mut buffer = [0u8; 1024];

                if let Ok(bytes_read) = stream.read(&mut buffer) {
                    let request = String::from_utf8_lossy(&buffer[..bytes_read]);
                    println!("--- Request ---\n{}", request);

                    // Build an HTTP response
                    let body = "<html><body><h1>Hello from Rust!</h1></body></html>";
                    let response = format!(
                        "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
                        body.len(),
                        body
                    );

                    // Write the response bytes to the stream
                    stream.write_all(response.as_bytes()).unwrap();
                    stream.flush().unwrap();

                    println!("--- Response sent ({} bytes) ---", response.len());
                }
            }
            Err(e) => {
                eprintln!("Connection failed: {}", e);
            }
        }
    }
}

Key details:

  • stream.write_all(response.as_bytes()) writes every byte of the response. Unlike .write(), which might write only part of the data, .write_all() keeps going until everything is sent.
  • stream.flush() ensures the bytes are actually pushed to the network, not sitting in an internal buffer.
  • response.as_bytes() converts the String to a &[u8] byte slice - the Write trait works with raw bytes, not strings.
  • The response includes Content-Length - this tells the client exactly how many bytes to expect in the body. Without it, the client wouldn't know when the response ends.
Warning
We're calling .unwrap() on write_all and flush. If the client disconnects before we finish writing, these will panic and crash our server. That's fine for learning - we'll handle this properly with error propagation in Chapter 8.

Testing with curl and a Browser

Run the server with cargo run. Now let's test it properly.

With curl

# Basic request - shows just the body
curl http://127.0.0.1:8080

# Show response headers too
curl -i http://127.0.0.1:8080

# Verbose - shows the full request AND response
curl -v http://127.0.0.1:8080

The -v flag is your best friend during development. It shows every byte exchanged - the request your client sent and the response your server returned. Lines starting with > are what curl sent, lines with < are what it received:

> GET / HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/html
< Content-Length: 51
<
<html><body><h1>Hello from Rust!</h1></body></html>

With a browser

Open http://127.0.0.1:8080 in your browser. You should see "Hello from Rust!" rendered as an HTML heading. The browser sends a GET request, your server reads it, and sends back HTML. That's a working webserver.

Tip
You'll notice the browser makes two requests - one for the page and one for /favicon.ico. Both get the same "Hello from Rust!" response because our server ignores the path entirely. We'll fix that in the next chapter when we parse the request properly.

With multiple requests

Try hitting the server rapidly with several requests:

# Send 5 requests in quick succession
for i in $(seq 1 5); do curl -s http://127.0.0.1:8080; echo; done

They all work - but they're handled one at a time. While the server is processing one connection, the others queue up. This is fine for now, but in Chapter 12 we'll add concurrency to handle multiple connections simultaneously.

Understanding the Full Flow

Let's trace exactly what happens when a request comes in, connecting the Rust code to the network concepts:

1

TcpListener::bind asks the OS to allocate a socket and bind it to the address. The OS starts queuing incoming connections.

2

listener.incoming() calls accept() under the hood. This blocks until a client connects, then returns a TcpStream.

3

stream.read(&mut buffer) reads bytes the client sent. For HTTP, this is the request line and headers as plain text.

4

We build a response string following the HTTP format: status line, headers, blank line, body.

5

stream.write_all sends the response bytes back through the TCP connection. flush ensures they leave immediately.

6

The stream goes out of scope at the end of the loop iteration. Rust's ownership system drops it, which closes the TCP connection. No manual cleanup needed.

Note
Step 6 is ownership in action. The TcpStream implements the Drop trait - when it's dropped, it closes the underlying socket. You never need to remember to call .close(). This is Rust's version of RAII (Resource Acquisition Is Initialization) from C++, and it applies to files, network connections, locks, and any other resource.

Putting It Together

Let's refactor into a cleaner structure with a handle_connection function. This is the pattern we'll build on for the rest of the guide:

src/main.rs
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};

/// Handle a single client connection.
fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0u8; 1024];

    let bytes_read = match stream.read(&mut buffer) {
        Ok(n) => n,
        Err(e) => {
            eprintln!("Read error: {}", e);
            return;
        }
    };

    let request_text = String::from_utf8_lossy(&buffer[..bytes_read]);

    // Extract the first line (e.g. "GET / HTTP/1.1")
    let request_line = request_text.lines().next().unwrap_or("");
    println!("{}", request_line);

    // For now, always respond with the same page
    let body = "<html>\
        <head><title>Rust Server</title></head>\
        <body>\
            <h1>Hello from Rust!</h1>\
            <p>You requested: <code>{REQUEST_LINE}</code></p>\
        </body>\
    </html>"
        .replace("{REQUEST_LINE}", request_line);

    let response = format!(
        "HTTP/1.1 200 OK\r\n\
         Content-Type: text/html\r\n\
         Content-Length: {}\r\n\
         Connection: close\r\n\
         \r\n\
         {}",
        body.len(),
        body
    );

    if let Err(e) = stream.write_all(response.as_bytes()) {
        eprintln!("Write error: {}", e);
        return;
    }
    let _ = stream.flush();
}

fn main() {
    let addr = "127.0.0.1:8080";
    let listener = TcpListener::bind(addr).unwrap();
    println!("Listening on http://{}", addr);

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => handle_connection(stream),
            Err(e) => eprintln!("Connection failed: {}", e),
        }
    }
}

Notice that handle_connection takes ownership of the TcpStream - not a reference. The function owns the connection for its entire duration, and when it returns, the stream is dropped and the connection is closed. This is a natural fit for the ownership model.

Also notice we added Connection: close to the headers. This tells the client we'll close the connection after the response. Without it, some clients keep the connection open waiting for more data.

Exercise

  1. Modify the server to listen on a different port. Try 0.0.0.0:8080 instead of 127.0.0.1:8080 - what's the difference? (Hint: try connecting from another device on your network.)
  2. Add a Connection: close header and verify with curl -v that the connection closes after the response.
  3. Print the client's IP address and a timestamp for each request. Use stream.peer_addr() for the address. For a simple timestamp, count requests with a mut counter variable before the loop.
  4. Try sending a request with a body: curl -X POST -d "hello" http://127.0.0.1:8080. Look at the raw bytes your server receives. Can you see the body after the blank line?
  5. What happens if you open the URL in a browser and look at the server logs? How many requests does the browser make? What are they?

What's Next

You have a working TCP server that accepts connections, reads requests, and sends responses. It's crude - it ignores the request method and path, always returns 200, and handles one connection at a time - but the networking layer works.

In the next chapter, we'll parse HTTP requests by hand. We'll extract the method, path, and headers from those raw bytes, plug them into the Request struct from Chapter 4, and route to different responses based on what the client asked for.