Engineering Atlas › System Design Fundamentals

Choosing between TCP and UDP

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The TCP state machine in production

The state machine is specified in RFC 9293, which in 2022 replaced the original RFC 793 and folded in decades of updates. The side that sends FIN first passes through FIN_WAIT_1 and FIN_WAIT_2 into TIME_WAIT. The other side sits in CLOSE_WAIT until its application calls close(). Thousands of CLOSE_WAIT sockets therefore mean your code leaks connections. A large TIME_WAIT count is normal on the closing side: it lasts 2×MSL (60 seconds on Linux), so stray segments cannot corrupt a new connection with the same 4-tuple.

Handshakes queue twice: half-open connections in the SYN queue, completed ones in the accept queue, bounded by the listen() backlog and net.core.somaxconn (4,096 by default since Linux 5.4, 128 before). When the accept queue overflows, the kernel drops packets and the client's initial RTO of one second starts. Classic exponential backoff resends the SYN at 1, 3 and 7 seconds; since Linux 6.5 the first four retries are linear, one second apart, copying macOS and iOS (kernel ip-sysctl, commit). Either way, a slow accept() loop becomes one-second latency spikes. Under a SYN flood, SYN cookies encode state in the initial sequence number so the server stores nothing.

Congestion control

At most min(cwnd, rwnd) bytes may be unacknowledged. Reno uses additive increase, multiplicative decrease: slow start adds one segment per ACK (doubling per round trip) up to ssthresh, congestion avoidance adds about one segment per round trip, three duplicate ACKs trigger fast retransmit and halve the window, and a timeout resets cwnd to one.

CUBIC, the Linux default (RFC 9438), grows the window as a cubic function of time since the last loss: fast back towards the old peak, careful near it, then accelerating. Both treat loss as the signal, so they fill router buffers first, and deep buffers add hundreds of milliseconds of delay (bufferbloat). BBR instead estimates bottleneck bandwidth and minimum RTT and paces at that rate. It keeps queues short and tolerates random loss: in Google's illustration on a 10 Gbps, 100 ms path with 1% loss, CUBIC managed about 3.3 Mbps and BBR over 9,100 Mbps (Google Cloud). It can take more than its share against CUBIC.

Head-of-line blocking

If segment 41 is lost, segments 42 to 80 wait in the kernel for at least one more round trip. HTTP/2 multiplexes many requests over one TCP connection, so one lost packet stalls every stream. At 2% loss on a mobile link, HTTP/1.1 with six connections can beat HTTP/2 with one. A related stall is Nagle's algorithm (hold small writes until earlier data is ACKed) meeting delayed ACKs (wait up to about 40 ms): a request written in two pieces waits 40 ms for nothing, which is why RPC frameworks set TCP_NODELAY.

What you take on with UDP

UDP keeps datagram boundaries and adds nothing else. You own congestion control (RFC 8085 requires it; an unthrottled sender starves every TCP flow on the link). You own amplification defence: a small spoofed request that triggers a large response is a DDoS weapon, and a 15-byte memcached request over UDP drew a 750 KB response, about 51,000x (Cloudflare). Keep payloads near 1,200 bytes to avoid fragmentation, and send keepalives. NATs are required to hold UDP mappings for at least two minutes, but many expire them sooner, so RFC 8085 allows keepalives as often as every 15 seconds, and no more often. Raw UDP fits when late data is worthless: game snapshots, voice and video over RTP, DNS, StatsD metrics.

QUIC: a transport in user space

QUIC (RFC 9000) implements reliability, congestion control and multiplexed streams in a library over UDP. Loss on one stream stalls only that stream. TLS 1.3 is built in, so a new connection is ready after one round trip and a resumed one can send immediately (0-RTT), which can be replayed, so 0-RTT must carry only idempotent requests. Connection IDs, not the 4-tuple, name the connection, so a phone moving from Wi-Fi to LTE keeps it. Packet numbers are never reused, so RTT samples are unambiguous, and the encrypted header stops middleboxes from ossifying the protocol. The costs: more CPU per byte than kernel TCP with offloads, and networks that block UDP, so clients keep a TCP fallback.

Down the stack

Layer 1The contract

TCP: bytes in order exactly once, or an error; write() returning means only "copied to the kernel", not "processed by the peer". UDP: a delivered datagram is whole, with no promise of arrival, uniqueness or order. QUIC: TCP-like guarantees within each stream, none across streams, plus optional unreliable datagrams (RFC 9221). Fairness between flows is a convention of congestion control, not something the network enforces.

Layer 2The mechanism

TCP options carry most of the modern machinery. MSS is negotiated on SYN. Window scaling shifts the 16-bit window by up to 14 bits, allowing windows up to 1 GB. SACK blocks report up to four received ranges, or three when timestamps share the 40 bytes of option space, so the sender retransmits only the holes (RFC 2018). Timestamps support RTT measurement and PAWS. Linux detects loss with RACK-TLP: it marks a segment lost when a later one is ACKed and a reordering window (starting at a quarter of the minimum RTT) has passed, and sends a tail-loss probe after about two smoothed RTTs instead of waiting for the full RTO (RFC 8985). The RTO itself follows RFC 6298, with a Linux floor of 200 ms.

QUIC packets carry frames: STREAM (stream ID, offset, data), ACK (ranges plus delay), CRYPTO, MAX_DATA/MAX_STREAM_DATA for flow control, and PATH_CHALLENGE for migration. Lost frames are resent in new packets, never the same packet.

Layer 3The machine

Throughput on one TCP flow is bounded by window ÷ RTT. Filling 1 Gbps over a 150 ms path needs a bandwidth-delay product of 18.75 MB in flight. For over a decade Linux's default maximum receive buffer (tcp_rmem max) was 6 MB, which caps such a flow at about 320 Mbps whatever the link speed. Linux 6.16 (2025) raised the default to 32 MB, but older kernels are still everywhere, so check before you assume (kernel ip-sysctl, commit). Kernel TCP is cheap per byte because TSO and GRO move 64 KB per stack traversal. UDP paid a syscall per datagram until sendmmsg/recvmmsg and UDP GSO (UDP_SEGMENT); QUIC servers still spend noticeably more CPU per gigabit than TCP+TLS. Dropped UDP datagrams show up as RcvbufErrors in netstat -su when the application reads more slowly than the socket buffer fills.

Why it's built this way

  1. IP drops and reorders packets, and most applications want a clean stream, so TCP put reliability in the kernel, shared by every program.
  2. Routers give no explicit congestion signal, so TCP inferred congestion from loss, which works but fills buffers, so delay-aware algorithms like BBR followed.
  3. One ordered stream per connection was simple, but multiplexing many requests on it created head-of-line blocking.
  4. Kernels and middleboxes made TCP slow to change, so the new transport was built on UDP in user space, encrypted so the network cannot ossify it again.

Diagram

stateDiagram-v2
    [*] --> LISTEN: listen()
    LISTEN --> SYN_RECEIVED: recv SYN, send SYN-ACK
    [*] --> SYN_SENT: connect(), send SYN
    SYN_SENT --> ESTABLISHED: recv SYN-ACK, send ACK
    SYN_RECEIVED --> ESTABLISHED: recv ACK
    ESTABLISHED --> FIN_WAIT_1: close(), send FIN
    ESTABLISHED --> CLOSE_WAIT: recv FIN
    CLOSE_WAIT --> LAST_ACK: close(), send FIN
    LAST_ACK --> [*]: recv ACK
    FIN_WAIT_1 --> FIN_WAIT_2: recv ACK
    FIN_WAIT_2 --> TIME_WAIT: recv FIN, send ACK
    TIME_WAIT --> [*]: 2 x MSL

Worked example

A team replicates a database from Frankfurt to Singapore: 160 ms RTT, a 1 Gbps link, 0.01% random packet loss on one leg.

Window limit. Bandwidth-delay product = 125 MB/s × 0.16 s = 20 MB. With tcp_rmem max at 6 MB, the default on kernels before 6.16, one connection tops out at 6 MB ÷ 0.16 s ≈ 37.5 MB/s, about 300 Mbps.

Loss limit. For Reno-style AIMD, the Mathis approximation gives throughput ≈ (MSS ÷ RTT) × 1.22 ÷ √p. With MSS 1,448 bytes, that is 9,050 bytes/s × 122 ≈ 1.1 MB/s, about 9 Mbps. CUBIC does better on this path but is still loss-limited to tens of Mbps. Replication lag grows all day.

Fixes, compared.

The team picks BBR plus larger buffers and verifies with ss -ti (cwnd, rtt, retrans, delivery_rate).

Head-of-line blocking at the edge. A mobile page loads 80 resources (about 120 packets) over one HTTP/2 connection with 100 ms RTT and 2% loss: 2 to 3 losses, each stalling every stream for a round trip or a 200 ms+ timeout, roughly 300 to 600 ms added. Over HTTP/3 each loss delays only the streams in that packet.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
TCP (kernel)Universal, cheap per byte with offloads, mature toolingHOL blocking, 4-tuple-bound connections, slow to evolveServer-to-server RPC, databases, bulk transfer
TCP + BBR and tuned buffersHigh throughput on long or lossy pathsCan be unfair to CUBIC flows, needs pacingCross-region replication and backups
Raw UDP + app logicLowest latency, app decides what to dropYou build rate control, security and NAT handlingGames, real-time media, DNS, telemetry
QUIC / HTTP/3No cross-stream HOL, 1-RTT/0-RTT setup, migrationMore CPU, UDP blocked on some networks, newer toolingMobile and web clients on variable networks
Several TCP connectionsIsolates loss per connection, simpleMore handshakes, unfair, more server stateHTTP/1.1 clients, parallel bulk downloads

Pitfalls in production

Leadership lens

Check yourself

  1. Why can HTTP/2 be slower than HTTP/1.1 on a lossy mobile network?

    Show answer

    HTTP/2 puts every stream on one TCP connection, so each lost segment stalls all streams until it is retransmitted. HTTP/1.1 spreads requests over several connections, so a loss stalls only one.

  2. A service shows 20,000 sockets in CLOSE_WAIT. Whose bug is it?

    Show answer

    Usually your own. CLOSE_WAIT means the peer sent FIN and the local application has not closed the socket, so a code path is leaking connections.

  3. Doubling link bandwidth did not speed up a cross-region transfer. Name two transport limits that could explain it.

    Show answer

    The window, where throughput is at most window ÷ RTT so small buffers cap it, and loss-based congestion control, where random loss keeps cutting cwnd. Larger buffers and BBR address each.

  4. What does QUIC's connection ID give you that TCP's 4-tuple cannot?

    Show answer

    The connection survives a change of client IP or port, such as Wi-Fi to cellular or a NAT rebinding, because packets are matched by ID rather than by addresses.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →