TCP vs UDP
3-way handshake, ordered delivery + retransmit, vs fire-and-forget.
This interactive explanation is built for system design interview prep: step through TCP vs UDP, watch the internal state change, and connect the concept to real distributed-system trade-offs.
Overview
TCP and UDP are the two transport protocols that carry almost every packet on the internet, and they make opposite trade-offs. TCP is a reliable, ordered, connection-oriented stream: every byte you write is guaranteed to arrive, in order, or you get an error. UDP is a best-effort datagram: you send a packet and hope it arrives. TCP gives you retransmission, flow control, congestion control, and connection state, which cost a three-way handshake (1 RTT before any data) and a per-connection buffer on both sides. UDP gives you none of that, which saves the handshake entirely — you send data with the first packet — and makes UDP the right choice when latency matters more than reliability (DNS, VoIP, video, game state) or when you want to build your own reliability on top (QUIC, custom protocols). Knowing which one to use and how each behaves under loss is fundamental to designing any networked system.
How it works
A TCP connection opens with a three-way handshake. The client sends SYN with its initial sequence number x; the server responds SYN-ACK with its own y and ack x+1; the client sends ACK y+1. At this point both sides have agreed on starting sequence numbers and window sizes. Data transfer uses sequence and acknowledgment numbers to detect loss; every segment is acked, and unacked segments are retransmitted after a timeout (RTO) or after three duplicate acks (fast retransmit). Flow control uses a receive window advertised by the receiver; congestion control (CUBIC, BBR) uses a congestion window maintained by the sender that grows on success and shrinks on loss. Connection close is a four-way handshake (FIN, ACK, FIN, ACK) or a one-shot RST. TCP buffers data on both sides: a slow reader stalls the sender because the receive window closes. UDP has no handshake, no sequence numbers, no acks, no buffering. A UDP send becomes a single IP packet (or fragmented if larger than MTU) and is handed to the network immediately. The sender does not know if it arrived. The receiver gets a datagram or nothing. Reorderings, duplicates, and losses are all silent. Applications that use UDP either do not care (DNS retries on timeout; VoIP tolerates lost samples) or implement their own reliability (QUIC has TCP-equivalent reliability on top of UDP, plus 0-RTT resumption). NIO in Java exposes both via SocketChannel (TCP) and DatagramChannel (UDP).
Implementation
public class TcpClient {
public static String fetch(String host, int port, String req) throws IOException {
try (SocketChannel ch = SocketChannel.open()) {
ch.configureBlocking(true);
ch.connect(new InetSocketAddress(host, port)); // 3-way handshake here
ch.write(ByteBuffer.wrap(req.getBytes(StandardCharsets.UTF_8)));
ByteBuffer buf = ByteBuffer.allocate(4096);
StringBuilder out = new StringBuilder();
while (ch.read(buf) > 0) {
buf.flip();
out.append(StandardCharsets.UTF_8.decode(buf));
buf.clear();
}
return out.toString();
}
}
public static void main(String[] args) throws IOException {
String resp = fetch("example.com", 80,
"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n");
System.out.println(resp.substring(0, Math.min(200, resp.length())));
}
}
public class UdpClient {
public static void sendAndForget(String host, int port, byte[] payload) throws IOException {
try (DatagramChannel ch = DatagramChannel.open()) {
// No connect/handshake required for UDP.
ch.send(ByteBuffer.wrap(payload), new InetSocketAddress(host, port));
// Receiver may or may not have gotten it; we don't know.
}
}
/** Optional: 'connect' a datagram channel to set a default peer; still no handshake. */
public static ByteBuffer requestResponse(String host, int port, byte[] req, int timeoutMs)
throws IOException {
try (DatagramChannel ch = DatagramChannel.open()) {
ch.connect(new InetSocketAddress(host, port));
ch.write(ByteBuffer.wrap(req));
ByteBuffer resp = ByteBuffer.allocate(1500); // MTU-sized
ch.socket().setSoTimeout(timeoutMs);
ch.read(resp);
resp.flip();
return resp;
}
}
}
Complexity
- TCP handshake:
1 RTT before first byte - TCP close:
1 RTT - UDP handshake:
0 RTT - TCP retransmit timeout:
200 ms - seconds - UDP loss detection:
app-layer, none at transport
Key design decisions & trade-offs
- TCP vs UDP — Chosen: TCP for reliable streams, UDP for latency-sensitive or custom reliability. TCP's retransmission adds unbounded latency on loss; UDP lets the app choose what to retransmit.
- Nagle's algorithm — Chosen: Disable (TCP_NODELAY) for interactive protocols. Nagle batches small writes to reduce packet count but adds up to 200 ms latency on keystroke-style traffic.
- Head-of-line blocking — Chosen: Accept on TCP, avoid via UDP/QUIC. TCP guarantees order per stream, so one lost packet stalls all later bytes; QUIC multiplexes streams to avoid this.
Common pitfalls
- Expecting UDP delivery — always handle loss at the app layer
- Leaving sockets in TIME_WAIT for 2 MSL and running out of ports at high connection rates
- Not setting SO_KEEPALIVE and holding dead connections forever behind a NAT
- Sending UDP payloads larger than MTU and suffering IP fragmentation
- Forgetting that TCP is a stream, not messages — you must frame your own boundaries
Interview follow-ups
- Move to QUIC / HTTP/3 for 0-RTT reconnect and stream multiplexing
- Tune congestion control (BBR for high-BDP links)
- Enable TCP Fast Open for 0-RTT repeat connections
- Use SO_REUSEPORT to spread accept across worker threads
Recommended reading
- Alex Petrov, Database Internals — storage engines and distributed systems internals.
- Martin Kleppmann, Designing Data-Intensive Applications (DDIA) — data models, replication, partitioning, consistency.
- The System Design Primer — high-level design building blocks.
- Foundational networking + web-security references (TCP/IP, TLS 1.3, OWASP Top 10).