Deep divePublished 4 min read
Sockets for Dummies: The server-side story you never understood
Part 1 of Networking for dummies
Finally understand what listen(), accept(), and the TCP handshake really do. No more confusion.
In this post
- Why the server socket never connects to anyone
- What socket(), bind() and listen() actually set up in the kernel
- How the kernel completes the TCP handshake while your code sleeps
- What accept() hands back, and why there are two kinds of sockets
- What happens when 10 clients connect at once
- The whole flow in a Go echo server, plus the usual confusions answered
On this page
- The mental model
- Setting up the listener: socket(), bind(), listen()
- Step 1: socket() - Create the structure
- Step 2: bind() - Assign an address
- Step 3: listen() - Prepare the queues
- The handshake happens without you
- accept() hands you a new socket
- Two types of sockets
- What happens with 10 clients at once?
- Seeing it for real: the complete picture in Go
- Common confusions, answered
You’ve read socket tutorials. You’ve copy-pasted server code. But deep down, you’re still confused about what listen() and accept() actually do. Why are there two sockets? Who does the handshake? Let’s fix that.
In this post I follow one server socket from socket() to accept(), and show what the kernel does in between while your code sits there waiting.
The mental model
Here’s the key insight:
The server socket never connects to anyone. It just sits there, listening. The kernel does all the handshake work behind your back, and gives you a brand new socket for each client.
YOUR CODE KERNEL ───────────── ──────────socket() → Creates fd=3 Allocates structurebind(:6969) → "I want port 6969" Maps port to socketlisten() → "Start accepting" Creates connection queues
[your code sleeps]
Client sends SYN → ← Kernel sends SYN-ACK Client sends ACK → Kernel creates fd=5 (new socket!) Puts fd=5 in accept queue
accept() → "Give me one" Returns fd=5 Now you have fd=5!See that? Your code did nothing during the handshake. The kernel handled the entire TCP dance automatically.
Setting up the listener: socket(), bind(), listen()
Step 1: socket() - Create the structure
int fd = socket(AF_INET, SOCK_STREAM, 0); // Returns fd=3This creates a structure in the kernel. Think of it as reserving a slot in a table:
File descriptor table (your process):┌─────┬──────────────────────────┐│ fd │ What it points to │├─────┼──────────────────────────┤│ 0 │ stdin ││ 1 │ stdout ││ 2 │ stderr ││ 3 │ Socket (UNCONNECTED) │ ← New!└─────┴──────────────────────────┘At this point, the socket has no IP, no port, no nothing. It’s just an empty structure.
Step 2: bind() - Assign an address
bind(fd, &addr, sizeof(addr)); // addr = 0.0.0.0:6969Now the kernel knows: “When packets arrive at port 6969, they belong to fd=3.”
Kernel's port table:┌───────┬─────────────────┐│ Port │ Socket │├───────┼─────────────────┤│ 22 │ sshd ││ 80 │ nginx ││ 6969 │ fd=3 (yours!) │ ← Registered└───────┴─────────────────┘Step 3: listen() - Prepare the queues
listen(fd, 128); // 128 = backlog sizeThis is where people get confused. listen() does not wait for connections. It just:
- Marks the socket as a listener (state: LISTEN)
- Creates two queues for incoming connections
Socket fd=3 (LISTEN mode):┌─────────────────────────────────────────────────┐│ ││ ┌─────────────────────────────────────────┐ ││ │ SYN Queue (half-open connections) │ ││ │ Clients that sent SYN, waiting for ACK │ ││ │ [empty] │ ││ └─────────────────────────────────────────┘ ││ ││ ┌─────────────────────────────────────────┐ ││ │ Accept Queue (completed connections) │ ││ │ Handshake done, waiting for accept() │ ││ │ [empty] │ ││ └─────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────┘The handshake happens without you
Now your code calls accept() and blocks. Meanwhile, a client somewhere does:
// CLIENT CODEconnect(client_fd, &server_addr, sizeof(server_addr));Here’s what happens in your server’s kernel, without your code doing anything:
TIME CLIENT SERVER KERNEL YOUR CODE──── ────── ──────────── ─────────t=0 accept() → sleeping
t=1 ──── SYN ────────────────→ Receives SYN Creates entry in SYN queue
←─── SYN-ACK ──────────── Sends SYN-ACK
t=2 ──── ACK ────────────────→ Receives ACK Handshake complete!
Creates NEW socket (fd=5) - state: ESTABLISHED - has read/write buffers - knows client IP:port
Moves fd=5 to Accept Queue
t=3 accept() returns fd=5!The kernel did the entire TCP handshake. Your code just woke up and got a fresh socket.
accept() hands you a new socket
int client_fd = accept(fd, &client_addr, &addr_len); // Returns fd=5accept() does this:
- Looks at the accept queue of fd=3
- If empty: sleeps until something arrives
- If not empty: pops the first completed connection
- Returns the new socket (fd=5) for that specific client
Now your file descriptor table looks like:
┌─────┬──────────────────────────────────────────┐│ fd │ What it points to │├─────┼──────────────────────────────────────────┤│ 0 │ stdin ││ 1 │ stdout ││ 2 │ stderr ││ 3 │ Listener socket (LISTEN, no buffers) ││ 5 │ Client socket (ESTABLISHED, has buffers)│ ← New!└─────┴──────────────────────────────────────────┘Two types of sockets
This is the key distinction that clears up all confusion:
| Aspect | Listener socket (fd=3) | Connection socket (fd=5) |
|---|---|---|
| State | LISTEN | ESTABLISHED |
| Purpose | Accept new connections | Send/receive data |
| Has data buffers? | No | Yes (read + write) |
| Has connection queues? | Yes (SYN + Accept) | No |
| Created by | Your code (socket()) | The kernel |
| One per server? | Yes | One per client |
| Can you read/write? | No | Yes |
What happens with 10 clients at once?
Say 10 clients connect simultaneously:
Client 1 ──SYN──→ │Client 2 ──SYN──→ │ KERNEL YOUR CODEClient 3 ──SYN──→ │ ────── ─────────Client 4 ──SYN──→ │Client 5 ──SYN──→ │ Handles 10 handshakes accept() → gets fd=5Client 6 ──SYN──→ │ in parallel accept() → gets fd=6Client 7 ──SYN──→ │ accept() → gets fd=7Client 8 ──SYN──→ │ Creates fd=5..14 accept() → gets fd=8Client 9 ──SYN──→ │ ...Client 10 ──SYN──→ │ Puts all in accept queue accept() → gets fd=14The kernel handles all 10 handshakes in parallel, creates 10 new sockets, and queues them all. Your code just calls accept() 10 times to retrieve them.
Seeing it for real: the complete picture in Go
In Go, net.Listen does socket(), bind() and listen() in one call, and Accept() is the accept() from above:
package main
import ( "fmt" "net")
func main() { // socket() + bind() + listen() all in one listener, err := net.Listen("tcp", ":6969") if err != nil { panic(err) } defer listener.Close()
fmt.Println("Listening on :6969") // At this point: fd=3 is in LISTEN state // Kernel is ready to handle handshakes
for { // accept() - blocks until a connection is ready conn, err := listener.Accept() if err != nil { fmt.Println("Accept error:", err) continue }
// conn is a NEW socket (fd=5, fd=6, ...) // Each client gets their own socket go handleClient(conn) }}
func handleClient(conn net.Conn) { defer conn.Close()
// Now you can read/write on this specific connection buf := make([]byte, 1024) for { n, err := conn.Read(buf) if err != nil { return } conn.Write(buf[:n]) // Echo back }}Common confusions, answered
“Does the listener socket do the handshake?”
No. The listener socket never sends or receives TCP segments. The kernel handles the handshake and gives you a new socket when it’s done.
“Can I read data from the listener socket?”
No. listener.Read() doesn’t exist. The listener has no data buffers, only connection queues. You read from the sockets that Accept() returns.
“What if I call accept() before any client connects?”
Your code blocks (sleeps) until the kernel puts a completed connection in the accept queue. Then it wakes up and returns that socket.
“What if clients connect while I’m handling a previous one?”
The kernel handles their handshakes and queues the new sockets. They wait in the accept queue until your code calls accept() again.
“Why not just use one socket for everything?”
Because each TCP connection needs its own state: sequence numbers, window size, buffers, remote address. One socket per connection keeps things isolated.
TL;DR
socket()creates an empty structure, returns file descriptorbind()assigns IP:port to your socketlisten()creates connection queues, marks socket as listener- The kernel does the entire TCP handshake automatically
accept()pops a completed connection and returns a NEW socket- Listener socket: has queues, no buffers, never sends data
- Connection socket: has buffers, no queues, send/receive data
- One listener, many connection sockets (one per client)
Materials used to write this article
More of what I read in my software-engineering-guide.
Related posts
Deep dive
From git push to a Running Pod: nobody is orchestrating anything
The image never reaches the API server. What reaches it is a few hundred bytes of YAML. Tracing the real path from a commit to a process inside a container — a chain of independent loops, none of which knows the others exist.
onKubernetes & Platform postsLinux & Networking posts
14 min read
Deep dive
Go's HTTP Connection Pool: The Key Is the Server, Not the Endpoint
I thought I understood net/http's connection pool. Then I found out the pool key ignores the path entirely, and that my own Transport config had silently turned off HTTP/2. Three discoveries, measured with httptrace.
onGo postsLinux & Networking posts
8 min read · Part 2 of net/http under the hood