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:
- 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." - A client (browser, curl, another program) opens a TCP connection to that address. TCP guarantees that bytes arrive in order and without corruption.
- The OS hands your server a socket - a bidirectional byte stream. You can read bytes the client sent and write bytes back.
- 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:
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 aResult- 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 newResult<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:8080You'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.
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:
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 ofu8with 1024 elements, all initialized to 0."stream.read(&mut buffer)fills the buffer and returnsResult<usize>- the number of bytes actually read. A real HTTP request is usually much less than 1KB.String::from_utf8_lossyconverts 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
mutbecause 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:
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 theStringto a&[u8]byte slice - theWritetrait 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.
.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:8080The -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.
/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; doneThey 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:
TcpListener::bind asks the OS to allocate a socket and bind it to the address. The OS starts queuing incoming connections.
listener.incoming() calls accept() under the hood. This blocks until a client connects, then returns a TcpStream.
stream.read(&mut buffer) reads bytes the client sent. For HTTP, this is the request line and headers as plain text.
We build a response string following the HTTP format: status line, headers, blank line, body.
stream.write_all sends the response bytes back through the TCP connection. flush ensures they leave immediately.
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.
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:
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
- Modify the server to listen on a different port. Try
0.0.0.0:8080instead of127.0.0.1:8080- what's the difference? (Hint: try connecting from another device on your network.) - Add a
Connection: closeheader and verify withcurl -vthat the connection closes after the response. - 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 amut countervariable before the loop. - 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? - 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.