backend / networking / 02_tcp_vs_udp.md

TCP vs UDP

5 interview angles 4 min read source

TCP vs UDP

Two transport-layer protocols. TCP is reliable and connection-oriented. UDP is fast and best-effort. Almost every Python backend you’ve ever written uses TCP (HTTP, gRPC, Postgres, Redis). UDP shows up for DNS, NTP, video/voice, and observability (Statsd, syslog, traces).

Side-by-side

TCP UDP
Connection yes (3-way handshake) no (just send a packet)
Reliability guaranteed delivery + order none
Retransmission yes no (app must do it)
Flow control yes (window) no
Congestion control yes no
Header size 20 bytes 8 bytes
Use cases HTTP(S), SSH, DBs, gRPC DNS, NTP, VoIP, video, metrics, QUIC base
Latency higher (handshake + ACKs) lower
“Stream” or “message”? byte stream discrete datagrams

TCP three-way handshake

Client  ──── SYN ────▶ Server
Client  ◀── SYN+ACK ── Server
Client  ──── ACK ────▶ Server
( connection established )

Cost: 1 RTT before any data flies. For a Python service in us-east-1 calling another in eu-west-1 (~80ms RTT), every new TCP connection burns 80ms. This is why connection pooling (requests.Session, httpx.AsyncClient, DB pools) matters.

TCP four-way close

Client ── FIN ──▶ Server
Client ◀── ACK ── Server
Client ◀── FIN ── Server
Client ── ACK ──▶ Server

After the close, the initiating side stays in TIME_WAIT for ~2× MSL (60–120s) to absorb stragglers. Lots of TIME_WAIT sockets on a busy short-connection service is a real production issue — fix with keepalive / pooling.

When UDP makes sense

  • You can tolerate loss (a dropped audio frame is better than a 200ms stall to retransmit).
  • Latency dominates throughput. No handshake means the first byte arrives faster.
  • Many small messages. TCP’s per-stream state is overhead.
  • Your protocol does its own reliability. QUIC (HTTP/3) is UDP underneath because TCP head-of-line blocking hurts multiplexed streams.

QUIC / HTTP/3 — why UDP came back

TCP has a head-of-line problem with multiplexed streams (HTTP/2 puts 100 requests on one TCP connection; one lost packet stalls them all). QUIC reimplements reliability + ordering on UDP, per stream, so a lost packet only stalls its own stream. HTTP/3 is HTTP over QUIC over UDP. Your Python code doesn’t notice unless you specifically opt in.

TCP states (netstat -ant lingo)

State Means
LISTEN server socket waiting for connections
ESTABLISHED open connection, data flowing
SYN_SENT / SYN_RECV mid-handshake
FIN_WAIT_1, FIN_WAIT_2, CLOSE_WAIT half-closed, waiting on the other side
TIME_WAIT post-close, draining stragglers
CLOSED gone

In production debugging:

  • Lots of TIME_WAIT on a client → too many short connections, missing keep-alive.
  • Lots of CLOSE_WAIT on a server → app isn’t calling close() on its side.
  • SYN_SENT accumulating → can’t reach the server (firewall, dropped packets).

Python sockets

import socket

# TCP server
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 9000))
s.listen(128)
conn, addr = s.accept()
data = conn.recv(4096)

# UDP server
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
u.bind(("0.0.0.0", 9001))
data, addr = u.recvfrom(4096)

Note SOCK_STREAM (TCP) gives you recv (a stream — you may get partial messages and have to buffer); SOCK_DGRAM (UDP) gives you recvfrom (one packet per call, with the sender address).

TCP gotcha: recv() doesn’t give you “one message”

conn.recv(4096) returns up to 4096 bytes, but could give you 1, 50, or all of them — TCP is a byte stream, not a message protocol. You need a length prefix or delimiter to frame messages. This bites people writing custom TCP protocols in Python; HTTP and gRPC handle it for you.

Common interview confusions

  • “Does HTTPS use TCP?” — yes (HTTP/1 and HTTP/2). HTTP/3 uses UDP via QUIC.
  • “Why isn’t gRPC over UDP?” — it’s HTTP/2 (TCP). gRPC over QUIC exists but isn’t mainstream yet.
  • “Is DNS TCP or UDP?” — both. UDP for normal queries (under 512 bytes), TCP for zone transfers and oversized responses (DNSSEC etc.).
  • “Does UDP have ports?” — yes. Same 0–65535 port space conceptually, but separate from TCP — UDP/53 (DNS) and TCP/53 (DNS zone transfer) are different sockets.

Interview angle

  • “When would you choose UDP over TCP?” — loss-tolerant low-latency traffic (voice/video, metrics, DNS), or when you build your own reliability on top (QUIC). Default is TCP.
  • “What’s the cost of opening a new TCP connection vs reusing one?” — 1 RTT for the handshake, plus TLS adds 1–2 more RTTs. With 80ms RTT that’s 240ms before the first HTTP byte. Pool connections to avoid this.
  • “What’s TIME_WAIT and why does it matter?” — post-close state that holds the socket for ~2× MSL. Too many on a busy client = port exhaustion. Fix with keepalive, pooling, or SO_REUSEADDR/SO_REUSEPORT.
  • “Why does HTTP/3 use UDP?” — TCP’s head-of-line blocking hurts HTTP/2 multiplexed streams. QUIC moves reliability into the protocol itself, per-stream, on UDP.
  • “Two Python processes communicate over TCP — what could go wrong with recv(4096)?” — TCP is a byte stream. recv can return partial messages or multiple messages glued together. You must frame with a length prefix or delimiter.