What Is a Connection?
Note: these posts are AI-assisted explorations written for my own understanding, not as definitive reference material. If you spot any inaccuracies, I'd love to hear about them.
You click a button. A name appears on screen.
One click. One name. 200 milliseconds.
But behind that click: JavaScript fires an event. The browser resolves a hostname through a hierarchy of nameservers. It negotiates a TLS-encrypted TCP connection, serializes an HTTP request, sends it through a load balancer to a backend server. The backend acquires a database connection from a pool; a pool that saved it from a 10-millisecond handshake ceremony by keeping a pre-established TCP session alive. It queries PostgreSQL through a wire protocol, receives the response back through every layer in reverse, decrypts, parses, updates the DOM, and composites new pixels to the display.
This post traces everything that happens. From click to pixels.
The Journey
Q1: You Click a Button. What Happens First?
You’re on a web page. You click “Load More.” An event handler fires.
button.addEventListener('click', async () => { const res = await fetch('/api/users/42'); const user = await res.json(); document.getElementById('name').textContent = user.name;});Three lines of JavaScript. The click event propagates through the DOM. The browser’s event loop picks up the listener and calls it. fetch() fires. The browser’s networking layer takes over.
But look at that URL: /api/users/42. A relative path. No server name. No IP address. No port number. The code doesn’t say where to send this request. The browser infers the destination from the page’s origin; https://api.example.com.
So now the browser has a job: send bytes to api.example.com. But that’s a name, not an address. Computers don’t route packets to names. They route to numbers.
How does a name become a number?
Q2: Where Is api.example.com? DNS Resolution
fetch() targets api.example.com. The browser needs a number; 203.0.113.50, something routable. It needs to translate the hostname.
This sounds simple. Look it up in a table somewhere.
But whose table? There are over 350 million registered domain names. No single machine holds all of them. No single organization controls them all. And the mappings change constantly; a domain can point to a new IP at any moment. Where do you even start looking?
The browser checks its own DNS cache first. If you visited this site recently, the answer is already there. Miss. It asks the operating system’s resolver. The OS checks its cache. Miss.
The OS sends a UDP packet to port 53 of a recursive resolver (usually your ISP’s, or a public one like 8.8.8.8 or 1.1.1.1). The resolver walks the DNS hierarchy:
- Ask a root nameserver (one of 13 global clusters): “Where is
api.example.com?” Root says: “I don’t know, but.comis handled by these TLD servers.” - Ask a .com TLD server: “Where is
api.example.com?” TLD says: “I don’t know, butexample.comis handled byns1.example.comat198.51.100.1.” - Ask the authoritative nameserver at
198.51.100.1: “Where isapi.example.com?” It replies: “203.0.113.50. TTL: 300 seconds.”
The recursive resolver caches the answer for 300 seconds (the TTL). The OS caches it. The browser caches it. Next time, no network trip at all.
If DNS fails (resolver unreachable, domain doesn’t exist), fetch() rejects with a network error. No TCP, no TLS, no HTTP. The name has to resolve first. Everything starts here.
Q3: The Browser’s Connection Pool
The browser has an IP: 203.0.113.50. Time to connect.
Except; maybe it already is.
Opening a TCP connection takes a round trip. Adding TLS takes another. That’s ~100ms cross-continent; just for the privilege of being allowed to send data. If the browser did this for every request on a page; images, scripts, API calls, fonts; pages would take seconds to load.
So browsers cheat. They keep connections alive after using them, pooled and ready. Chrome maintains up to 6 simultaneous TCP connections per origin under HTTP/1.1. If an idle keep-alive connection to api.example.com:443 exists from a previous request, the browser reuses it. No handshake. No waiting.
Under HTTP/2, something better: a single TCP connection with multiplexed streams. Multiple requests share one connection, each on its own numbered stream. No head-of-line blocking at the HTTP layer. (Though TCP-level head-of-line blocking persists; a single lost packet stalls all streams. This is why HTTP/3 abandons TCP entirely for QUIC over UDP.)
This is the first encounter with a pattern you’ll see again: connection reuse. The browser does for HTTP connections what a database pool does for PostgreSQL connections. Same economics, different layer. We’ll see the database version in Q9.
If no reusable connection exists, the browser creates one. That means a TCP handshake.
Q4: TCP Handshake; Browser to Backend
No idle connection exists. The browser needs to create one.
The internet is unreliable. Packets get lost, duplicated, reordered, delayed. Routers crash. Links go down. And yet TCP promises you a reliable, ordered byte stream over this chaos.
How? It starts with three packets:
- SYN: The browser says “I want to connect.” Picks a random starting sequence number.
- SYN-ACK: The server says “OK, and I want to connect too.” Picks its own sequence number.
- ACK: The browser says “Got it.”
Three packets. One round trip. On a LAN, ~0.5 ms. Cross-continent, ~100 ms. And after these three packets, both sides have agreed on initial state; sequence numbers, buffer sizes, options; and the connection is open.
Behind these three packets, the kernel on both sides allocates data structures, picks ephemeral ports, and runs a TCP state machine. There’s a lot happening inside those three packets. We’ll trace every struct, every state transition, every timer when the backend connects to the database in Q15. For now: three packets, one round trip, connection open.
But the connection is plaintext.
Q5: TLS; How HTTPS Encrypts the Channel
Every router, switch, and WiFi access point between you and the server can read every byte. Your password. Your session token. Your credit card number. TCP guarantees delivery but promises zero privacy.
HTTPS fixes this by wrapping TCP in TLS (Transport Layer Security). But there’s a problem: how do two machines that have never communicated establish a shared encryption key, over a channel that anyone can eavesdrop on?
TLS 1.3 solves it in one round trip:
ClientHello (browser → server): “Here are the cipher suites I support, and here’s my half of the key exchange (an ECDHE public key).”
ServerHello + Certificate + Finished (server → browser): “I chose this cipher suite. Here’s my certificate (proving I’m really api.example.com). Here’s my half of the key exchange. I’m done.”
The browser verifies the certificate: is it signed by a trusted Certificate Authority? Is it expired? Does it match the hostname? If any check fails, the connection is aborted and you see a browser warning.
Both sides now derive the same symmetric encryption key from the ECDHE exchange. From this point, every byte is encrypted with AES-GCM or ChaCha20. The server can’t be impersonated. The data can’t be read in transit.
Q6: The HTTP Request
Two handshakes done. Two round trips spent. The browser has a reliable, encrypted byte stream.
Now it can finally ask for the thing you wanted: user 42.
The browser constructs an HTTP/2 request:
The request is a GET /api/users/42 with headers: Host, Accept: application/json, Authorization (bearer token or cookie), Accept-Encoding: gzip, br. HTTP/2 compresses these headers with HPACK (a dictionary-based scheme that avoids sending Host: api.example.com as full text every time).
The HTTP/2 frame is handed to TLS for encryption, then to TCP for delivery, then to IP for routing. Each layer wraps the previous one. The request is now a series of encrypted TCP segments flying across the network.
Q7: Load Balancers and Reverse Proxies
The request leaves the browser. It arrives at 203.0.113.50. But here’s the thing: that’s not your application server. The browser thinks it’s talking to the backend. It’s not.
It’s talking to a load balancer; Nginx, HAProxy, AWS ALB, or Cloudflare.
An L7 (application-layer) load balancer terminates TLS here. It unwraps the encryption, reads the HTTP headers, and decides which of several backend instances should handle this request (round-robin, least-connections, or content-based routing). It then opens a second connection to the chosen backend; often plain HTTP internally, since the data center’s internal network is trusted.
Two TCP connections. Two sockets. Two 4-tuples. The browser’s encrypted connection to the load balancer, and the load balancer’s plaintext connection to the backend. The load balancer stitches them together, forwarding bytes between them. And it maintains its own pool of persistent connections to backend instances. Another pool. The pattern keeps repeating.
Q8: The Backend Receives the Request
The backend server (FastAPI, Express, Spring) has been waiting. Not busy-waiting; not spinning in a loop. It’s asleep. Its event loop called epoll_wait() and the kernel put the process to sleep; zero CPU usage, zero wasted cycles. It will wake up only when data arrives on one of its sockets. This is the same multiplexing mechanism we’ll explore deeply in Q26.
A connection from the load balancer has data. The kernel wakes the process. The framework reads the HTTP request, parses the path /api/users/42, matches a route handler.
The handler runs. It needs user data from the database. It calls pool.acquire().
We’ve gone from click to pool.acquire() in about 4 milliseconds (assuming cached DNS, same-region server). Now we go deeper; into the kernel, into the TCP state machine, into the database. Everything from here is the backend talking to PostgreSQL, and this is where we trace every struct, every syscall, every byte.
Q9: pool.acquire(). What Happens First?
The pool is a data structure in your application’s memory. Two collections: an idle queue (connections not in use) and an active set (connections checked out to callers). When you call acquire(), the logic is simple:
if idle_queue is not empty: return idle_queue.pop()
else if total_connections < max_size: create_new_connection() ← this is where it gets interesting
else: wait until someone returns a connection (or timeout, if configured)This is the same pattern you saw in Q3: the browser checked its connection pool before opening a new TCP connection. Same economics, different layer. The browser pools HTTP connections; the backend pools database connections. Reuse is always cheaper than creation.
At pool creation, asyncpg eagerly opened min_size=5 TCP connections to the database and completed the PostgreSQL authentication handshake on each one. They’re sitting in the idle queue. So on your first acquire(), the pool just pops one off a deque. No network round-trip. No waiting. About 0.1 microseconds.
But what did the pool hand you? A Python object. And inside that object, a transport. And inside that, a socket. And inside that, a number.
What number?
Q10: What IS a “Connection”?
A connection isn’t one thing. It’s a stack of representations, each layer wrapping the one below:
asyncpg.Connection ← Python object .transport → asyncio.Transport ._sock → socket.socket(fd=7) ← Python wrapper fd 7 → kernel struct socket ← OS kernel handle → struct sock (TCP state machine) ← TCP protocol state → 4-tuple (IPs + ports) ← Network identityAt the top: an object with .fetch() methods and cached prepared statements. At the bottom: four numbers; source IP, source port, destination IP, destination port. (192.168.1.50, 49152, 10.0.0.5, 5432). That 4-tuple is the connection’s identity on the network. No two connections in the world share the same one at the same time.
You can see this yourself:
conn = await pool.acquire()sock = conn._protocol.transport.get_extra_info('socket')print(sock.fileno()) # 7print(sock.getpeername()) # ('10.0.0.5', 5432)print(sock.getsockname()) # ('192.168.1.50', 49152)The pool doesn’t really “pool connections.” It pools file descriptors with established TCP sessions and authenticated PostgreSQL protocol state. That’s the expensive thing. That’s what it’s saving you from recreating.
But what is a file descriptor? Why is everything hiding behind an integer?
Q11: What’s Behind That Integer?
Every running process has a file descriptor table; a kernel data structure your program can’t touch directly, only through system calls. It’s an array of pointers. The integer 7 is an index.
Here’s what that integer actually points to:
fd[7] → struct file: holds a table of function pointers (f_op). This is how the kernel knows that read(7, ...) should call socket receive code, not filesystem code. It also holds f_flags (like O_NONBLOCK) and a pointer to…
struct socket: the kernel’s VFS-facing representation. Holds the socket state (connected? listening?), the type (SOCK_STREAM for TCP), and a pointer to…
struct sock: the real workhorse. This is where the TCP state machine lives. Send buffer, receive buffer, packet queues, retransmission timers. For TCP, this is actually embedded inside struct tcp_sock, which adds sequence numbers, congestion window, and RTT estimates. And that embeds struct inet_sock, which adds the 4-tuple: source IP, source port, destination IP, destination port.
All from a single integer. 7.
The kernel always assigns the lowest available integer. This is why stdin is 0, stdout is 1, stderr is 2, and your first opened file or socket gets 3.
If a process opens sockets without closing them, the fd table fills up. At the limit, socket() returns -1 and sets errno to EMFILE: “too many open files.” This is one of the most common production failures for long-running servers. ls /proc/<pid>/fd | wc -l tells you how close you are.
Now you know what a connection is. How does the kernel create one?
Q12: The socket() Syscall Creates an Endpoint
When the pool needs a new connection, the first step is a system call:
int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);Three arguments. AF_INET: IPv4. SOCK_STREAM: reliable byte stream. IPPROTO_TCP: the TCP protocol. The kernel:
- Allocates a
struct socketfrom its slab allocator (a memory pool for fixed-size objects) - Looks up the TCP protocol handler (registered at boot time)
- Allocates a
struct tcp_sock(~2,300 bytes). Setssk_state = TCP_CLOSE. No sequence numbers yet. Congestion control set to CUBIC. - Creates a
struct file, wires its function pointers to socket operations - Finds the lowest unused slot in the fdtable, installs the pointer
- Returns the integer
About 2 microseconds. Three kernel allocations and a table lookup.
At this point, you have a socket. Not a connection.
A socket is a telephone that exists but hasn’t dialed anyone. It has kernel data structures, a file descriptor, a TCP state machine in CLOSE state. But no IP addresses, no ports, no network activity whatsoever.
A connection requires two sockets that have completed a handshake.
Q13: connect() Sends the First Packet
connect(fd, {port: 5432, ip: "10.0.0.5"}, ...);In Q4, we said “the browser sends a SYN.” Here’s what that actually means inside the kernel.
Three things happen:
First, the kernel picks a source address. You didn’t call bind() (client code rarely does), so the kernel chooses now. The source IP comes from the routing table; which network interface will this packet leave from. The source port comes from the ephemeral port range: 32,768 to 60,999 on Linux. That’s 28,232 possible ports. The kernel picks one not already in use for this destination.
Second, the kernel fills in the 4-tuple. inet_saddr = 192.168.1.50, inet_sport = 49152, inet_daddr = 10.0.0.5, inet_dport = 5432. The connection now has an identity.
Third, the kernel builds a SYN packet and sends it.
The Initial Sequence Number is semi-random (hash of 4-tuple + secret + clock) to prevent packets from old connections being mistaken for new ones. The socket transitions to TCP_SYN_SENT and a retransmission timer starts: if no response in 1 second, resend the SYN. Up to 6 retries over ~127 seconds before giving up.
For a non-blocking socket (which asyncio uses), connect() returns immediately with EINPROGRESS. The application will find out the connection succeeded via epoll; we’ll get to that.
If the server is down, the SYN gets no response. Your application hangs for two minutes as the kernel retransmits with exponential backoff. If a firewall silently drops the packet (“black hole”), same thing. If nothing is listening on port 5432, the server immediately sends back a RST (reset) and connect() fails with ECONNREFUSED.
But the server is up. It’s been waiting.
Q14: The Database Server Was Already Waiting
Before any client can connect, the server called two functions: bind() and listen().
bind() told the kernel: “associate this socket with port 5432 on all interfaces.” The kernel registered it in a hash table.
listen() is the critical one. It does two things. It transitions the socket to TCP_LISTEN. This socket will never send or receive data; it exists solely to accept incoming connections. And it creates two queues:
The SYN queue (half-open connections): clients that have sent a SYN, gotten a SYN-ACK back, but haven’t completed the handshake yet. Each entry is a lightweight request_sock (~256 bytes), much smaller than a full tcp_sock.
The accept queue (fully established): connections that finished the handshake but haven’t been picked up by the application yet. The backlog parameter to listen() caps this queue, further limited by net.core.somaxconn (4,096 on modern kernels; historically 128).
Our SYN arrives. The kernel finds the listening socket via hash lookup (~100 nanoseconds), creates a request_sock in the SYN queue, and sends back a SYN-ACK.
What happens if the accept queue overflows? By default, the kernel silently drops the client’s final ACK. The client thinks it’s connected (it sent the ACK), but the server discarded it. The client then sends data into a void and eventually times out. This is one of the most confusing failure modes in production. netstat -s | grep "listen queue" shows overflow counts.
Q15: The Three-Way Handshake; Now Inside the Kernel
This is the same handshake from Q4. But now you can see inside.
When the browser connected to the backend in Q4, this exact machinery ran. Three packets, same state machine, same kernel structs. Now you see what was behind “three packets, one round trip.”
Step by step:
SYN (client → server): Client picks ISN=1000, sends SYN, enters SYN_SENT.
SYN-ACK (server → client): Server creates a request_sock, picks its own ISN=5000, sends a packet with both SYN and ACK flags. The ack field is 1001; client’s ISN plus one, because the SYN itself consumes a sequence number.
ACK (client → server): Client receives the SYN-ACK, validates the sequence numbers, transitions to ESTABLISHED. Sends ACK with ack=5001. The client can start sending data immediately, piggybacked on this ACK.
When the server receives the final ACK, it promotes the request_sock to a full tcp_sock and moves the connection to the accept queue. The connection sits there until the application calls accept().
If the final ACK is lost, the server retransmits the SYN-ACK. The client, already in ESTABLISHED, re-sends the ACK. Self-healing.
Q16: accept() Creates a New Socket
The connection is in the accept queue. PostgreSQL calls accept():
int new_fd = accept(listening_fd, &client_addr, &addr_len);// new_fd = 8The key insight: accept() creates a brand-new file descriptor. The listening socket (fd 4) is unchanged. It keeps listening. The new socket (fd 8) represents this specific connection:
fd 4 → listening socket (LISTEN) on port 5432 ← still listeningfd 8 → connected socket (ESTABLISHED) ← new! local: 10.0.0.5:5432 remote: 192.168.1.50:49152Both sockets use port 5432. No conflict. The kernel distinguishes them because the listening socket matches all incoming SYNs to port 5432, while each connected socket has a unique 4-tuple. Incoming data packets are routed to the correct socket via hash table lookup on the 4-tuple.
ss -tnp | grep 5432# LISTEN 0.0.0.0:5432 *:* postgres (fd=4)# ESTAB 10.0.0.5:5432 192.168.1.50:49152 postgres (fd=8)# ESTAB 10.0.0.5:5432 192.168.1.60:51234 postgres (fd=9)Three sockets. One port. The 4-tuple makes each unique.
Q17: PostgreSQL Wire Protocol; the Application Handshake
TCP is a raw byte pipe. It doesn’t understand PostgreSQL, HTTP, or any application protocol. Before the pool can send queries, it completes the PostgreSQL wire protocol handshake:
The browser negotiated TLS over TCP in Q5. Now the backend negotiates PostgreSQL over TCP. Same pattern: the transport is dumb bytes; the protocol gives them meaning.
- Client sends
StartupMessage: protocol version, username, database name - Server responds with
AuthenticationRequest: could be SCRAM-SHA-256 (the modern default), taking 2–3 more round trips of challenge-response - Server sends
ReadyForQuerywith transaction statusI(idle)
Now the connection is ready for SQL.
Count the round trips to get here:
Compare to a pooled connection: pop from a deque. 0.001 ms.
That’s a 10,000x to 500,000x difference. This is the entire reason connection pools exist.
Q18: Bytes Through the Kernel
You call conn.fetch("SELECT * FROM users WHERE id = $1", 42). asyncpg serializes this into PostgreSQL’s extended query protocol: Parse, Bind, Execute, Sync messages. Those bytes hit socket.send(), which becomes a write() syscall.
The kernel copies the bytes from your process’s memory into the socket send buffer (sk_write_queue). This is a chain of sk_buff structures; the fundamental packet data structure in Linux networking. Each one is ~240 bytes of kernel metadata wrapping a chunk of data.
write() returns immediately once the data is copied to the send buffer. It doesn’t wait for the data to reach the server. It doesn’t even wait for it to leave the network card. The kernel handles the rest asynchronously.
On the server side, the reverse: NIC receives packets, kernel validates checksums, looks up the connection by 4-tuple, places data in the socket receive buffer (sk_receive_queue), sends back an ACK, and wakes up the PostgreSQL process if it’s blocked on read().
Two flow regulation mechanisms are at work. Flow control: the receiver advertises how much buffer space it has (the TCP window). If the server reads slowly and its receive buffer fills, it advertises a zero window; telling the sender “stop.” The sender pauses and probes periodically until space opens up.
Congestion control: separate from flow control. This limits the sending rate based on network congestion (packet loss). The congestion window starts small and grows. TCP’s CUBIC algorithm adjusts it based on observed loss patterns.
If the send buffer is full (the kernel can’t queue any more data), write() blocks on a blocking socket or returns EAGAIN on a non-blocking one.
Q19: Across the Wire
Briefly, because the depth of this journey is in the software:
The NIC reads the packet from a ring buffer via DMA (the hardware reads RAM without the CPU). Serializes it into electrical signals (copper Ethernet), light pulses (fiber), or radio waves (WiFi). The packet traverses switches (layer 2, MAC addresses) and routers (layer 3, IP addresses). Each router examines the destination IP and forwards it. In a data center: 1–3 hops. Across the internet: 10–20 hops.
| Path | Latency |
|---|---|
| Same machine (loopback) | ~10–50 microseconds |
| Same rack | ~100–200 microseconds |
| Same data center | ~0.5–1 ms |
| Same region (cross-AZ) | ~1–5 ms |
| Cross-continent | ~50–100 ms |
| Intercontinental | ~100–300 ms |
Each of these latencies is paid per round trip during connection setup. One more reason pooling matters more the farther your database is.
Q20: The Connection Returns to the Pool
PostgreSQL processed the query. The result bytes traveled back through the same stack in reverse. asyncpg deserialized the rows. Your async with block exits, calling pool.release(connection).
What happens:
- Validate state: Is the TCP connection still alive? Is the PostgreSQL session clean (not mid-transaction)? If it was in a transaction, asyncpg sends
RESET ALL. - Append to idle queue:
idle_connections.append(connection). Wake up anyone blocked onacquire(). - Done.
The TCP connection stays ESTABLISHED. The file descriptor stays open. The kernel’s tcp_sock stays allocated. The PostgreSQL server process stays alive. Nothing happens on the wire; no packets sent.
# The connections are ESTABLISHED even when idle:ss -tnp | grep 5432 | grep ESTAB# ESTAB 192.168.1.50:49152 10.0.0.5:5432 python (fd=7)# ESTAB 192.168.1.50:49153 10.0.0.5:5432 python (fd=8)
# PostgreSQL processes are idle:SELECT state FROM pg_stat_activity WHERE application_name = 'asyncpg';# state# -------# idle# idleThis is the whole trick. The connection is alive but unused. The next acquire() pops it off the deque in 0.1 microseconds, and you skip the 10–20ms connection setup entirely.
But idle connections aren’t free. Each one holds ~10–50 KB on the client and ~5–10 MB on the server (PostgreSQL’s forked process). And TCP keepalive probes won’t fire for two hours by default. The pool needs to manage its flock.
Q21: How the Pool Manages Its Flock
A production pool juggles several concerns at once:
Min/max enforcement: min_size=5 means the pool always keeps at least 5 connections alive; if one dies, it creates a replacement in the background. max_size=20 is a hard ceiling; any excess acquire() calls wait. This protects the database from overload.
Why min_size? Because creating a connection costs 10–20ms. If the pool drops to zero during a lull, the next burst of requests all pay that cost simultaneously. Keeping warm connections eliminates the cold start.
Idle timeout: connections idle beyond a threshold (typically 300–600 seconds) are closed to free resources, but never below min_size.
Max lifetime: even active connections are recycled after 30 minutes. This prevents memory leaks in the server process, catches stale DNS (if the database IP changed), and cleans up accumulated session state.
Health checking: before handing out an idle connection, does the pool validate it?
No validation: Fast, but may hand out dead connectionsValidate on acquire: Safe, adds ~0.5ms (a round trip)Background pinging: Best of both; a background task probes idle connections periodicallyHikariCP (the gold standard JVM pool) validates only connections idle longer than 30 seconds; balancing safety with speed.
Try sending a burst of requests. Watch what happens when the pool is exhausted; requests queue up. Then watch them drain as active connections finish and are reused. That reuse; the same slot going active → idle → active → idle; is the entire value of pooling.
The worst pool bug is a pool leak: code that acquires a connection but never releases it (an exception bypasses the finally block, or you forget). The pool thinks the connection is active. Over hours, all max_size connections are “in use” but actually abandoned. Symptom: acquire timeout errors under low load.
Q22: The Backend Builds an HTTP Response
The database rows are back. The pool connection is released. Now the backend builds the response.
The handler serializes the user object to JSON: {"id": 42, "name": "Alice", "email": "alice@example.com"}. Serialization is fast; json.dumps in Python walks the object and produces a string in ~10 microseconds for a small payload. But consider: every field the database returned is converted from PostgreSQL’s binary wire format to Python objects by asyncpg, and now those Python objects are converted again to JSON text. Two serialization steps, each with its own overhead.
The framework wraps this in an HTTP response: status 200 OK, headers Content-Type: application/json, Content-Length: 62, Cache-Control: no-cache. The Content-Length header matters; it tells the browser exactly how many bytes to expect, so it knows when the response is complete. Without it, the server would use chunked transfer encoding; sending the body in pieces with size prefixes, closing the stream to signal completion.
The framework also compresses the body. The request said Accept-Encoding: gzip, br, so the backend compresses the JSON with gzip or Brotli before sending. 62 bytes of JSON might compress to ~45 bytes; small savings. But a 500 KB API response compresses to ~50 KB. Compression runs on the CPU; decompression happens on the browser’s end. This tradeoff; CPU time for bandwidth; matters more as responses grow.
The HTTP/2 frame is handed to TLS for encryption (if the internal connection uses TLS) or sent as plaintext to the reverse proxy. The response bytes enter the TCP send buffer and begin their return journey.
Q23: The Response Travels Back
The response follows the same path in reverse: backend → load balancer → network → browser. But there’s no new connection. The browser’s TCP connection from Q4 is still open. The TLS session from Q5 is still active. TCP is full-duplex; both sides can send simultaneously on the same socket, using the same 4-tuple.
Something subtle happens here. On the forward path, the request was tiny; maybe 200 bytes of HTTP headers. The TCP congestion window (from Q18) was barely tested. But the response might be much larger. If this is the first response on a new TCP connection, the congestion window starts small (typically 10 segments, ~14 KB). A response larger than 14 KB can’t be sent all at once; TCP must wait for ACKs before sending more. This is TCP slow start, and it means the first response on a new connection is slower than subsequent ones. Yet another reason connection reuse matters; established connections have already grown their congestion windows.
The load balancer receives the response from the backend, re-encrypts it with TLS for the browser, and sends it out. TCP segments cross the network. The browser’s kernel receives them, places them in the socket receive buffer, and wakes up the networking thread.
Q24: The Browser Receives, Decrypts, Parses
The browser’s networking layer reassembles the TCP segments in order (using sequence numbers; the same mechanism from Q15). TLS decrypts each record. The HTTP/2 framing layer extracts the response headers and body. If the body was compressed, the browser decompresses it (gzip inflate or Brotli decode).
The fetch() Promise resolves with a Response object.
const res = await fetch('/api/users/42'); // ← Promise resolves hereconst user = await res.json(); // ← JSON parsed hereres.json() reads the response body as text and calls JSON.parse(). This is synchronous; it blocks the JavaScript thread until parsing is done. For a 62-byte response, it takes microseconds. For a 5 MB JSON payload, it can block for 50–100ms, freezing the UI. This is why large responses are sometimes streamed with ReadableStream or parsed in a Web Worker.
The await on line 2 yields to the event loop’s microtask queue. When json() completes, the microtask resolves, and the next line runs. The browser’s event loop is the same pattern as the backend’s; a single thread dispatching callbacks from a queue, never blocking on I/O. You saw this pattern in Q8 (backend epoll) and Q26 will explore it deeply.
res.json() parses the JSON string into a JavaScript object. Control returns to the event handler. The next line runs.
Q25: DOM Update; Pixels on Screen
document.getElementById('name').textContent = user.name;Setting .textContent triggers the browser’s rendering pipeline:
DOM mutation: the text node changes. Style calculation: the browser recomputes which CSS rules apply (in this case, nothing changes). Layout: the browser recalculates element positions (the text might be wider or narrower). Paint: the affected pixels are redrawn into a layer. Composite: the GPU composites all layers and sends the final frame to the display.
The user sees “Alice” appear.
The loop is complete. Click to pixels. Every layer traversed, twice; once on the way out, once on the way back.
Q26: How Does a Server Handle Thousands of Connections?
PostgreSQL has hundreds of connected sockets. The backend server has connections from the load balancer. Data could arrive on any socket at any time. Neither can call read() on each one in a loop; if 999 have no data and 1 does, it wasted 999 syscalls.
This is the I/O multiplexing problem: “I have N sockets. Tell me which ones are ready.”
The backend’s event loop in Q8 was waiting on epoll_wait() when the HTTP request arrived. This is the same mechanism.
Linux evolved through three solutions:
select() (1983, BSD): You give the kernel a bitmask of fds you care about. The kernel scans through all of them, checks which have data, returns a modified bitmask. Two problems: the bitmask is fixed at 1,024 bits (you literally cannot monitor more fds), and every call copies and scans the entire bitmask. O(N) every time.
poll() (1986, System V): Uses an array of structs instead of a bitmask. No fd limit. But still copies the entire array in and out on every call. Still O(N).
epoll (2002, Linux 2.5.44): The breakthrough. You register sockets once with epoll_ctl(). The kernel maintains a red-black tree of your registered fds. When data arrives on a socket, the network stack calls a callback that adds this socket to a ready list. When you call epoll_wait(), the kernel just hands you what’s on the ready list.
100,000 registered sockets5 have data ready
select: scan 100,000 → return 5 O(N)poll: scan 100,000 → return 5 O(N)epoll: return 5 O(ready)If you have 100,000 connections but only 5 have data, epoll_wait returns those 5 immediately. No scanning.
asyncio, Node.js, Go’s netpoller, Nginx, Redis; they all use epoll (Linux) or kqueue (macOS/BSD, same idea) under the hood. When your pool has 20 connections, asyncio has all 20 fds registered with epoll. When a query response arrives on one, epoll wakes the event loop, which dispatches to the right coroutine.
This is why a single-threaded Python asyncio or Node.js server can handle tens of thousands of concurrent connections. It’s never blocked waiting on the wrong socket.
Q27: TCP Teardown
A pooled connection lives for minutes or hours. But eventually it closes; the pool evicts it, the application shuts down, or the server restarts. TCP teardown takes four steps instead of three, because each direction closes independently:
TCP is full-duplex; data flows both directions independently. When the client sends FIN, it’s saying “I’m done sending.” But the server might still have data to send. So the server acknowledges the FIN, finishes sending, then sends its own FIN. In practice, the server’s ACK and FIN are often combined into one packet.
When close() is called, the kernel marks the fd slot as available, decrements the reference count on struct file, sends a FIN, and starts the teardown state machine.
If the server process crashes without closing its socket, the client sits in FIN_WAIT_2 until a timeout (60 seconds, tcp_fin_timeout). If the server machine dies (power loss), the client doesn’t know until TCP keepalive fires (default: 2 hours!) or the next write fails.
Q28: TIME_WAIT; The Ghost Holding Your Ports
After teardown, the client enters TIME_WAIT and sits there for 60 seconds. Doing nothing. Holding the 4-tuple hostage. Why?
Two reasons:
The final ACK might be lost. If it is, the server will retransmit its FIN. The client needs to still have a socket for this 4-tuple to re-send the ACK. Without TIME_WAIT, the client has no socket, sends a RST, and the server sees an error instead of a clean close.
Old packets might be lurking. Imagine this 4-tuple closes and immediately a new connection opens on the exact same 4-tuple. A delayed packet from the old connection arrives. The new connection accepts it as valid data. Silent corruption. TIME_WAIT keeps the 4-tuple reserved for 60 seconds so any old packets expire. (RFC 793 defines MSL as 2 minutes, making 2xMSL = 4 minutes. Linux ignores this and hardcodes 60 seconds.)
The problem: TIME_WAIT sockets consume resources. Each holds an inet_timewait_sock (~168 bytes of kernel memory) and occupies an ephemeral port.
Start the simulation and watch. Without pooling, at 100 requests/second, each creating and closing a connection, you hit steady state at 6,000 TIME_WAIT sockets. At 500/second: 30,000. But you only have 28,232 ephemeral ports. Boom.
With pooling: zero.
ss -s# TCP: 35421 (estab 250, closed 0, orphaned 0, timewait 32105)Connection pooling eliminates this problem entirely. Long-lived connections don’t close and reopen. No FIN, no TIME_WAIT, no port exhaustion.
Q29: HTTP Keep-Alive, TCP Keepalive, and Connection Pooling Are Three Different Things
The naming is confusing. They share words but are different mechanisms at different layers. You’ve now seen all of them in action:
TCP keepalive (kernel feature, one word): the kernel sends tiny probe packets on idle connections to detect if the remote side died. Default: probes start after 2 hours of silence. This is OS-level dead-peer detection.
HTTP keep-alive (protocol feature, hyphenated): HTTP/1.1 reuses a TCP connection for multiple serial requests instead of closing it after each response. The browser did this in Q3. One handshake, many requests. But requests are serial; request 2 waits for response 1. This is head-of-line blocking.
Connection pooling (application pattern): manages multiple keep-alive connections as a set. The browser’s pool in Q3. The database pool in Q9. The load balancer’s pool in Q7. Concurrent requests go to different connections. This solves head-of-line blocking.
HTTP/2 multiplexing: interleaves multiple request/response streams on a single TCP connection. Each stream has an ID; responses arrive in any order. With HTTP/2, you often need just one TCP connection per server. The browser used this in Q3.
Why does this matter for databases? Database connections are stateful; they have transactions, session variables, prepared statements. Each connection can handle only one query at a time. So pooling means maintaining multiple TCP connections and distributing queries across them. There’s no equivalent of HTTP/2 multiplexing for PostgreSQL; one connection, one query at a time.
Q30: PgBouncer and HikariCP; Two Architectures for Pooling
Everything so far has been about application-side pooling: your asyncpg or HikariCP pool, running inside your application process. But there’s another architecture.
Imagine five application instances, each with a pool of 20 connections:
Scale to 10 instances and you’ve doubled over max_connections. Raising max_connections is dangerous because each PostgreSQL connection holds ~5–10 MB of RAM (it’s a full OS process).
PgBouncer sits between applications and PostgreSQL. The diagram above shows the difference: five apps with 20 connections each means 100 direct connections. PgBouncer accepts all 100 client connections and multiplexes them onto just 20 real PostgreSQL connections.
PgBouncer is a single-threaded C program using libevent (which uses epoll). It accepts many client TCP connections and multiplexes them onto a small number of real PostgreSQL connections.
In transaction pooling mode (the most popular), a server connection is assigned when a transaction begins and returned when it commits. Between transactions, the client has no server connection; it’s available for others. A hundred clients can share ten server connections if most are idle between transactions.
The catch: no features that span transactions. SET timezone, PREPARE, LISTEN/NOTIFY; these live in the session and break when the server connection changes between transactions. This is the #1 PgBouncer footgun.
HikariCP is the opposite architecture: a library inside your JVM process. No proxy, no extra hop. It wraps each JDBC connection in a ProxyConnection that intercepts close():
Connection conn = dataSource.getConnection();// ... use it ...conn.close(); // Does NOT close TCP! HikariCP returns it to the pool.The proxy pattern; close() doesn’t close, it returns; is universal across connection pool implementations. asyncpg does the same thing with pool.release().
Q31: What Did Pooling Save You?
Here’s the full cost, side by side, for a database query over a LAN (0.5ms RTT):
Ten times faster. Zero port accumulation. A hundred times less memory on the server.
And that’s just the database layer. The browser saved a TCP + TLS handshake by reusing its HTTP connection (Q3). The load balancer saved another by pooling connections to backends (Q7). Connection reuse is the single most impactful optimization at every layer of this stack.
For cross-region connections (50ms RTT), the gap widens to 50x or more.
The Complete Loop
You click a button. JavaScript calls fetch(). The browser resolves api.example.com through a chain of nameservers. It reuses a TCP+TLS connection from its pool (or creates one: three TCP packets, one TLS round trip). It sends an HTTP/2 request through a load balancer to a backend server. The backend’s event loop wakes up, routes the request, and calls pool.acquire(). The pool pops a pre-established database connection; a Python object wrapping an asyncio transport wrapping a socket wrapping an integer; 7; that indexes into a kernel table pointing through three nested C structs to a TCP state machine. Your query is serialized into PostgreSQL wire protocol, copied into a kernel send buffer, segmented by TCP, wrapped in IP, transmitted as light or electricity, reassembled on the database server, parsed, executed. The response travels back through every layer in reverse. The backend serializes JSON, the load balancer forwards it, TLS encrypts it, TCP carries it, the browser decrypts it, JavaScript parses it, and the DOM updates. The GPU composites new pixels to the display.
You see “Alice.”
Connections at Every Level
| Layer | What a “connection” IS |
|---|---|
| Browser JavaScript | A fetch() Promise waiting to resolve |
| Browser networking | An HTTP/2 stream on a pooled TLS+TCP connection |
| TLS | A symmetric encryption session with derived keys |
| TCP (browser ↔ server) | A 4-tuple with a kernel state machine |
| Load balancer | Two connections stitched together (client-side + backend-side) |
| Backend application | An asyncpg.Connection object with .query() methods |
| Database connection pool | An entry in an idle queue or active set |
| Kernel VFS layer | An fd → struct file → struct socket |
| Kernel network layer | struct sock / tcp_sock with buffers, state machine, timers |
| Network identity | A 4-tuple: (src_IP, src_port, dst_IP, dst_port) |
| TCP protocol | A state machine: CLOSED → SYN_SENT → ESTABLISHED → FIN_WAIT → TIME_WAIT → CLOSED |
| Physical | Electrical signals or light pulses carrying serialized packet bytes |
Further Reading
- W. Richard Stevens, UNIX Network Programming, Volume 1 (2003). The definitive reference. Every concept in this post is explored in depth.
- RFC 793: Transmission Control Protocol (1981). The original TCP specification. Still the foundation.
- RFC 8446: TLS 1.3 (2018). The modern encryption layer for HTTPS.
- RFC 1034 / RFC 1035: Domain Name System (1987). How hostnames become IP addresses.
- RFC 1337: TIME-WAIT Assassination Hazards in TCP (1992). Why you can’t just skip TIME_WAIT.
- Dan Kegel, “The C10K Problem” (1999). The framing that motivated epoll.
- Brett Wooldridge, HikariCP Wiki. How to build a connection pool right.
- PgBouncer documentation. Server-side pooling for PostgreSQL.
- The Linux kernel source, particularly
net/ipv4/tcp.candfs/eventpoll.c. The actual implementation of everything discussed here.