runtime/rants

Deep divePublished 4 min read

Sockets for Dummies: The server-side story you never understood

onLinux & Networking posts

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

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 structure
bind(:6969) → "I want port 6969" Maps port to socket
listen() → "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=3

This 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:6969

Now 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 size

This is where people get confused. listen() does not wait for connections. It just:

  1. Marks the socket as a listener (state: LISTEN)
  2. 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.c
// CLIENT CODE
connect(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=5

accept() does this:

  1. Looks at the accept queue of fd=3
  2. If empty: sleeps until something arrives
  3. If not empty: pops the first completed connection
  4. 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:

AspectListener socket (fd=3)Connection socket (fd=5)
StateLISTENESTABLISHED
PurposeAccept new connectionsSend/receive data
Has data buffers?NoYes (read + write)
Has connection queues?Yes (SYN + Accept)No
Created byYour code (socket())The kernel
One per server?YesOne per client
Can you read/write?NoYes

What happens with 10 clients at once?

Say 10 clients connect simultaneously:

Client 1 ──SYN──→ │
Client 2 ──SYN──→ │ KERNEL YOUR CODE
Client 3 ──SYN──→ │ ────── ─────────
Client 4 ──SYN──→ │
Client 5 ──SYN──→ │ Handles 10 handshakes accept() → gets fd=5
Client 6 ──SYN──→ │ in parallel accept() → gets fd=6
Client 7 ──SYN──→ │ accept() → gets fd=7
Client 8 ──SYN──→ │ Creates fd=5..14 accept() → gets fd=8
Client 9 ──SYN──→ │ ...
Client 10 ──SYN──→ │ Puts all in accept queue accept() → gets fd=14

The 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:

main.go
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 descriptor
  • bind() assigns IP:port to your socket
  • listen() 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.

About the author

Joel Teodoro

Joel Teodoro

Backend engineer working with Go in production. I build small systems to understand how big ones work, and draw them here.