Sockets Under the Hood: What really happens when you make an HTTP request

4 min read

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:

  1. Has a file descriptor (it's a file for Unix, everything is a file)
  2. Lives in the kernel, not in your process
  3. Has send and receive buffers
  4. 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.

strace
$ strace -e socket curl -s google.com
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3

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:

  1. Sends SYN with a random sequence number
  2. Waits for SYN-ACK from the server
  3. Sends ACK and marks the connection as ESTABLISHED
tcpdump
$ tcpdump -i any port 80
10:23:45.123 IP client.54321 > server.80: Flags [S], seq 1234567890 10:23:45.145 IP server.80 > client.54321: Flags [S.], seq 987654321, ack 1234567891 10:23:45.146 IP client.54321 > server.80: Flags [.], ack 987654322
🚨Gotcha: connect() can block

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));
⚠️Warning

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 error

5. 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]
🚨Gotcha: TIME_WAIT accumulates sockets

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:

bash
$ ss -tan state established | head -5
Recv-Q Send-Q Local Address:Port Peer Address:Port 0 0 192.168.1.10:54321 142.250.185.78:443 0 36 192.168.1.10:54322 142.250.185.78:443
  • 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:

bash
$ ss -tan state time-wait | wc -l
2847

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

TL;DR
  • 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 -tan for 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.