Sockets Under the Hood: What really happens when you make an HTTP request
Sockets Under the Hood: What really happens when you make an HTTP request
Every time your application makes an HTTP request, there's a complex dance between your process, the kernel, and the network. Most devs never look beyond fetch() or http.Get(). Today we'll pop the hood and see what really happens.
Mental Model
Think of a socket as a bidirectional mailbox between two processes. But this mailbox:
- Has a file descriptor (it's a file for Unix, everything is a file)
- Lives in the kernel, not in your process
- Has send and receive buffers
- The kernel handles the entire TCP protocol for you
Your process Kernel Network
| | |
|--- write(fd, data) ----->| |
| |--- TCP segment ------->|
| |<-- ACK ----------------|
|<-- write() returns ------| |The trick is understanding that write() returns before the data reaches the destination. It only means the kernel accepted it into its buffer.
Under the Hood: The lifecycle of a connection
1. socket() - Create the file descriptor
int fd = socket(AF_INET, SOCK_STREAM, 0);This only creates a structure in the kernel. There's no connection yet.
The kernel reserves:
- Send buffers (~16KB by default)
- Receive buffers (~87KB by default)
- TCP control structures (timers, sequence numbers, etc.)
2. connect() - The three-way handshake
connect(fd, &server_addr, sizeof(server_addr));Here the magic begins. The kernel:
- Sends SYN with a random sequence number
- Waits for SYN-ACK from the server
- Sends ACK and marks the connection as ESTABLISHED
connect() blocks until the handshake completes or timeout. In Go, net.Dial() wraps this. The default timeout can be 2+ minutes. Always use context with deadline.
3. write() / send() - Send data
write(fd, "GET / HTTP/1.1\r\n...", len);Doesn't send anything to the network immediately. Data goes to the kernel buffer:
Your process Kernel send buffer Network
| | |
|-- write(data) -->| |
| | |
| (returns) |-- TCP pkt 1 --->|
| |-- TCP pkt 2 --->|
| |<--- ACK --------|The kernel decides when and how to segment the data (MSS, Nagle's algorithm, etc.).
4. read() / recv() - Receive data
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));read() does NOT guarantee receiving all the data that was sent. It can return fewer bytes than requested. Always use a loop.
// BAD
buf := make([]byte, 1024)
n, _ := conn.Read(buf) // may read only 100 bytes
// GOOD
buf := make([]byte, 1024)
_, err := io.ReadFull(conn, buf) // reads exactly 1024 or error5. close() - The four-way handshake
close(fd);TCP close is more complex than open:
Client Server
|--- FIN ------------------->|
|<-- ACK --------------------|
| | (server can still send)
|<-- FIN --------------------|
|--- ACK ------------------->|
| |
[TIME_WAIT 2*MSL] [CLOSED]The client stays in TIME_WAIT for ~60 seconds. If you open thousands of short connections, you run out of ephemeral ports. Solution: connection pooling.
Real example: Debugging with ss and netstat
See connection state:
- Recv-Q > 0: Data received that your app hasn't read
- Send-Q > 0: Data in buffer waiting for peer ACK
See sockets in TIME_WAIT:
If this number is too high, you need connection pooling or SO_REUSEADDR.
Code: TCP echo server in Go
package main
import (
"io"
"log"
"net"
)
func main() {
listener, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
defer listener.Close()
log.Println("Listening on :8080")
for {
conn, err := listener.Accept()
if err != nil {
log.Println("Accept error:", err)
continue
}
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
defer conn.Close()
// Important: always set deadlines
// conn.SetDeadline(time.Now().Add(30 * time.Second))
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
if err != nil {
if err != io.EOF {
log.Println("Read error:", err)
}
return
}
_, err = conn.Write(buf[:n])
if err != nil {
log.Println("Write error:", err)
return
}
}
}Gotchas and common mistakes
1. Not handling partial reads
// TCP is a stream, not messages
// If you send "HELLO" it can arrive as "HEL" + "LO"2. Ignoring deadlines
// Without deadline, Read() can block forever
conn.SetReadDeadline(time.Now().Add(30 * time.Second))3. Not closing connections properly
// File descriptor leak
conn, _ := net.Dial("tcp", "example.com:80")
// ... use
// forgot conn.Close()4. Confusing TCP and HTTP
TCP is a byte stream. HTTP adds structure (headers, body, delimiters). You can't do conn.Write([]byte("give me data")) and expect it to work with an HTTP server.
TL;DR Checklist
- TCP socket = file descriptor + buffers in kernel
-
connect()does three-way handshake (can block) -
write()returns when kernel accepts data, NOT when it arrives -
read()can return fewer bytes than requested, use loops -
close()leaves socket in TIME_WAIT ~60s on client - Always use deadlines on network connections
- Connection pooling to avoid handshake overhead
-
ss -tanfor socket debugging
Want me to go deeper into something specific? Nagle's algorithm, TCP congestion control, or how non-blocking sockets work are good candidates for a future post.