Choosing between TCP and UDP
Open in the interactive app: practice, quiz, mock interview →In one minute
- The real question is not "reliable or fast". It is who owns reliability, ordering and congestion control: the kernel's TCP, your own code on top of UDP, or a user-space transport such as QUIC.
- TCP gives one ordered byte stream per connection. That ordering is also its main cost: a single lost segment stalls every byte behind it, including unrelated HTTP/2 streams (head-of-line blocking).
- Congestion control decides throughput far more than link speed. Loss-based algorithms (Reno, CUBIC) and model-based ones (BBR) behave very differently on long, lossy paths.
- UDP is not "TCP without the slow parts". Using it means taking on rate control, amplification risk, NAT keepalives and MTU handling yourself.
- QUIC is the modern answer for clients: UDP underneath, with streams that do not block each other, TLS 1.3 built in, and connections that survive a change of IP address. The problem shapes are ordering (per-stream versus global) and overload (congestion control is backpressure for the shared network).
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
- IP drops and reorders packets, and most applications want a clean stream, so TCP put reliability in the kernel, shared by every program.
- Routers give no explicit congestion signal, so TCP inferred congestion from loss, which works but fills buffers, so delay-aware algorithms like BBR followed.
- One ordered stream per connection was simple, but multiplexing many requests on it created head-of-line blocking.
- 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 MSLWorked 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.
- Raise
tcp_rmem/tcp_wmemmax to 32 MB on both ends (the receive default from Linux 6.16): removes the window limit, not the loss limit. - Switch to BBR with the
fqqdisc: random loss no longer cuts the rate, and the flow reaches 600 to 800 Mbps. - Use 8 parallel streams: about 8x, at the cost of fairness and reassembly.
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
- GitHub, 2018: UDP amplification at 1.35 Tbps. On 28 February, attackers sent spoofed requests to memcached servers exposed on UDP, which answered GitHub with up to 51 KB for every byte sent. Traffic peaked at 1.35 Tbps and 126.9 million packets per second from tens of thousands of endpoints. GitHub was down for five minutes and recovered about nine minutes after detection by moving its BGP announcements to Akamai's scrubbing network (GitHub).
- Netflix, 2019: SACK Panic. Netflix disclosed that a crafted sequence of SACK blocks could trigger an integer overflow in Linux's TCP stack and panic the kernel remotely (CVE-2019-11477, affecting kernels from 2.6.29), with related bugs that fragmented the queue or forced 8-byte segments. Mitigations were kernel patches, a new
tcp_min_snd_msssysctl, or disabling SACK until patched (Netflix advisory).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| TCP (kernel) | Universal, cheap per byte with offloads, mature tooling | HOL blocking, 4-tuple-bound connections, slow to evolve | Server-to-server RPC, databases, bulk transfer |
| TCP + BBR and tuned buffers | High throughput on long or lossy paths | Can be unfair to CUBIC flows, needs pacing | Cross-region replication and backups |
| Raw UDP + app logic | Lowest latency, app decides what to drop | You build rate control, security and NAT handling | Games, real-time media, DNS, telemetry |
| QUIC / HTTP/3 | No cross-stream HOL, 1-RTT/0-RTT setup, migration | More CPU, UDP blocked on some networks, newer tooling | Mobile and web clients on variable networks |
| Several TCP connections | Isolates loss per connection, simple | More handshakes, unfair, more server state | HTTP/1.1 clients, parallel bulk downloads |
Pitfalls in production
CLOSE_WAITpile-up. The peer closed but your code never calledclose(), often on an error path. File descriptors run out. Fix the leak; there is no kernel tunable for it.- The 40 ms Nagle and delayed-ACK stall. Small request/response protocols show p50 latency of exactly 40 ms. Set
TCP_NODELAY, or write each message in one call. - UDP without rate control. A logging or metrics client floods a congested link and starves TCP traffic, or a public UDP service becomes a reflector. Pace sends, require a handshake or cookie before large responses, and rate-limit per source.
- 0-RTT replay. A resumed QUIC or TLS 1.3 client resends a POST in early data, and an attacker can replay it. Allow only idempotent methods in 0-RTT, or reject early data for state-changing endpoints.
Leadership lens
- Default to TCP for internal services and QUIC for public client edges. Ask anyone proposing raw UDP for business traffic to show their congestion control, security and NAT plan.
- Cross-region data movement is usually a transport-tuning problem before it is a bandwidth purchase. Ask for
ss -tioutput before approving a bigger link. - Put socket health on standard dashboards: retransmits,
CLOSE_WAIT, listen overflows, UDP receive errors. - HTTP/3 is a platform decision: edge CPU, UDP-capable load balancers, observability and a TCP fallback.
Check yourself
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.
A service shows 20,000 sockets in
CLOSE_WAIT. Whose bug is it?Show answer
Usually your own.
CLOSE_WAITmeans the peer sentFINand the local application has not closed the socket, so a code path is leaking connections.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.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
- RFC 9293: Transmission Control Protocol: the current TCP specification, replacing RFC 793 in 2022.
- RFC 8985: The RACK-TLP Loss Detection Algorithm: time-based loss detection and tail-loss probes, as used by Linux.
- RFC 9221: An Unreliable Datagram Extension to QUIC: latest-wins data inside a congestion-controlled QUIC connection.
Systems docs
- Linux ip-sysctl: defaults for
tcp_rmem,somaxconn, SYN retries and RACK, which change between kernel versions. - TCP BBR comes to GCP (Google Cloud): how BBR models the path, with YouTube production results.
Books
- Computer Networking: A Top-Down Approach, Kurose and Ross, chapter 3 "Transport Layer": reliable data transfer built up step by step, then TCP and congestion control.
- TCP/IP Illustrated, Volume 1 (2nd edition), Fall and Stevens, chapter 13 "TCP Connection Management" and chapter 16 "TCP Congestion Control".
- High Performance Browser Networking, Ilya Grigorik, chapter 2 "Building Blocks of TCP": free online; slow start, head-of-line blocking and the bandwidth-delay product from a latency point of view.
Other curricula
- donnemartin/system-design-primer: frames the choice as reliability versus latency with "use X when" lists; no congestion control, head-of-line blocking or QUIC.
- roadmap.sh: System Design: TCP and UDP as two short nodes that link back to the primer.
Watch
- TCP Congestion Control, Section 3.7 (Jim Kurose): slow start, AIMD and fast recovery, from the co-author of the standard textbook.
- HTTP/2 Critical Limitation that led to HTTP/3 & QUIC (Hussein Nasser): head-of-line blocking over TCP, shown by example.
- TCP vs. QUIC: Evolution of the Internet Transport Layer (Epic Networks Lab): how QUIC rebuilds transport features over UDP.
Free resources
- RFC 9000: QUIC: streams, connection IDs, frames and migration, from the specification.
- RFC 5681: TCP Congestion Control and RFC 9438: CUBIC: the standard algorithms behind most TCP traffic.
- RFC 8085: UDP Usage Guidelines: what every UDP application must handle, including congestion and MTU.
- The Road to QUIC (Cloudflare blog): why QUIC exists and how it fixes head-of-line blocking.
- This is strictly a violation of the TCP specification (Cloudflare blog): a deep dive into
CLOSE_WAITand Linux TCP edge cases. - Coping with the TCP TIME-WAIT state on busy Linux servers (Vincent Bernat): what
TIME_WAITprotects and which tunables are safe.