Layer 4 — How It Actually Works#
A packet arrives. The LB picks a server. But mechanically — how does it forward the packet, track the connection, and hide the server from the client?
NAT, connection tables, and the difference between TCP and UDP — all of it here.
The Backend Pool — How the LB Knows Its Servers#
The L4 LB doesn't discover servers on its own. You configure it with a backend pool — a list of servers it can forward to, the port they listen on, and which algorithm to use.
Backend Pool — Valorant Game Servers:
Server A: 10.0.0.3:7777 ✓ healthy
Server B: 10.0.0.5:7777 ✓ healthy
Server C: 10.0.0.7:7777 ✓ healthy
Protocol: UDP
Algorithm: Least Connections
Listen on: port 7777
When a new server spins up, it registers itself into the pool. When a server fails health checks, it's removed. The pool is dynamic — the LB's view of available servers updates continuously.
NAT — How the LB Hides Backend Servers#
The client connects to the LB's IP. It has no idea backend servers exist. The LB uses NAT (Network Address Translation) to make this work — it rewrites IP addresses on every packet.
On the way in (client → server):
Packet arrives at LB:
Source IP: 192.168.1.50:54321 (Valorant client)
Dest IP: 10.0.0.1:7777 (load balancer)
LB rewrites destination:
Source IP: 192.168.1.50:54321 (unchanged)
Dest IP: 10.0.0.5:7777 (Server B — swapped!)
LB forwards to Server B
On the way back (server → client):
Server B responds:
Source IP: 10.0.0.5:7777 (Server B)
Dest IP: 192.168.1.50:54321 (client)
LB rewrites source:
Source IP: 10.0.0.1:7777 (load balancer — swapped back!)
Dest IP: 192.168.1.50:54321 (client)
Client receives response from LB's IP — never saw Server B
TCP Full Walkthrough — PgBouncer (PostgreSQL Connection Pooling)#
PostgreSQL speaks its own binary wire protocol over TCP — not HTTP. An app server connecting to PostgreSQL through PgBouncer is a perfect L4 TCP example — one service, custom protocol, port 5432.
sequenceDiagram
participant App as App Server (192.168.1.50)
participant PB as PgBouncer L4 LB (10.0.0.1:5432)
participant DB as Postgres B (10.0.0.5:5432)
App->>PB: TCP SYN (dst: 10.0.0.1:5432)
Note over PB: Port 5432 → PostgreSQL pool<br/>Least Connections → picks DB B<br/>Rewrites dst: 10.0.0.5<br/>Records: 192.168.1.50:54321 → 10.0.0.5
PB->>DB: TCP SYN (dst: 10.0.0.5:5432)
DB->>PB: TCP SYN-ACK
Note over PB: Rewrites src: 10.0.0.5 → 10.0.0.1
PB->>App: TCP SYN-ACK (src: 10.0.0.1)
Note over App: TCP handshake complete ✓
App->>PB: PostgreSQL wire protocol — SELECT * FROM users WHERE id=7
Note over PB: Looks up connection table<br/>192.168.1.50:54321 → DB B<br/>Forwards to 10.0.0.5
PB->>DB: PostgreSQL wire protocol — SELECT * FROM users WHERE id=7
DB->>PB: Result rows (binary PostgreSQL format)
Note over PB: Rewrites src to 10.0.0.1
PB->>App: Result rows
Note over App: Got query results. Never knew DB B existed. Why not use L4 for HTTPS (port 443) routing to multiple services?
If Valorant had /login, /store, /skins, /matchhistory all arriving on port 443, L4 cannot tell them apart — they all look identical. It would blindly send all of them to the same server pool, which may be completely wrong for most requests. That's exactly L7's job — read the URL and route to the right service.
How the connection table tracks every packet across the connection's lifetime — see 05-Layer4-Connection-Tables.md
TCP vs UDP — The Core Difference#
Before the UDP walkthrough, you need to understand why UDP exists.
TCP — connection first, data second
Client → SYN → Server (want to talk?)
Client ← SYN-ACK ← Server (yes, ready)
Client → ACK → Server (connected)
Client → data → Server (actual request)
Client ← data ← Server (actual response)
Client → FIN → Server (done, closing)
TCP guarantees delivery — lost packets are resent. This adds latency. Fine for login, not fine for real-time gameplay.
UDP — just fire the packet
No handshake. No acknowledgment. No guarantee. If the packet is lost — it's gone. The game client doesn't wait for confirmation. It fires the next packet 8ms later anyway.
| TCP | UDP | |
|---|---|---|
| Handshake | Yes — 3 steps before any data | No |
| Delivery guarantee | Yes — resends lost packets | No |
| Speed | Slower | Much faster |
| Use when | Correctness matters (DB queries, file transfers) | Speed matters, loss is ok (positions, game state) |
UDP Full Walkthrough — Valorant Position Update#
Valorant runs at 128 tick rate — 128 position updates per second, every 8ms.
sequenceDiagram
participant Client as Valorant Client (192.168.1.50)
participant LB as L4 Load Balancer (10.0.0.1)
participant ServerB as Game Server B (10.0.0.5)
Note over Client: Player moved. Pack position into 14 bytes.
Client->>LB: UDP packet (dst: 10.0.0.1:7777, data: playerID=7 x=44 y=01 z=89)
Note over LB: Port 7777 → Game server pool<br/>IP Hash → always picks same server per player<br/>NAT: rewrites dst to 10.0.0.5<br/>UDP session table tracks this mapping
LB->>ServerB: UDP packet (dst: 10.0.0.5:7777, data: playerID=7 x=44 y=01 z=89)
Note over ServerB: Updates game state for player 7
ServerB->>LB: UDP packet (updated world state)
Note over LB: NAT: rewrites src to 10.0.0.1
LB->>Client: UDP packet (updated world state)
Note over Client: 8ms later — fires next position update What the Valorant client actually sends — raw binary, not HTTP:
# Inside Valorant client — simplified
import socket
import struct
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP socket
# Pack position as binary — 14 bytes total
# No HTTP headers. No JSON. Just raw numbers.
position_data = struct.pack('!HfffB',
player_id, # 2 bytes
x, # 4 bytes
y, # 4 bytes
z, # 4 bytes
) # = 14 bytes
# No connect() — UDP has no connection
# Just fire the packet
sock.sendto(position_data, ("10.0.0.1", 7777))
Compare to what an HTTP request looks like:
POST /api/v1/location HTTP/1.1
Host: game-server.valorant.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Content-Length: 28
{"x": 44, "y": 01, "z": 89}
~300 bytes with headers vs 14 bytes binary. 20x smaller. No TCP handshake. 128 times per second across 10 players — the difference is enormous.
How UDP session tables differ from TCP connection tables — and when UDP needs one at all — see 05-Layer4-Connection-Tables.md