TCP vs UDP: When to Actually Use Each

TL;DR

TCP guarantees delivery and order at the cost of latency. UDP is fast and lossy—use it when you control retries or can tolerate loss. Most apps use TCP. Games, video, DNS use UDP.

Every networking tutorial says "TCP is reliable, UDP is fast." That's true but useless until you understand why, and what you're actually trading off.

What TCP Guarantees

TCP guarantees:
  ✓ Delivery        — lost packets are retransmitted
  ✓ Order           — packets arrive in the order they were sent
  ✓ Error detection — corrupted packets are detected and dropped
  ✓ Flow control    — sender won't overwhelm receiver
  ✓ Congestion control — sender backs off when network is congested

None of this is free. Each guarantee adds overhead.

What TCP Actually Does

Before any data flows, TCP does a three-way handshake:

Client                     Server
  |                           |
  |──── SYN ─────────────────→|   "I want to connect"
  |←─── SYN-ACK ─────────────|   "OK, I'm here"
  |──── ACK ─────────────────→|   "Great, let's go"
  |                           |
  |──── data ────────────────→|
  |←─── ACK ─────────────────|   "Got it"

That's 1.5 round trips before your first byte of data. On a 100ms RTT connection, you've already burned 150ms before the server has seen your request.

For lost packets, TCP waits for an ACK, then retransmits:

Sender: packet 1, packet 2, packet 3
        (packet 2 is lost)
Receiver: got 1, got 3... waiting for 2
Sender: timeout → retransmit packet 2
        (now receiver can process 3)

This is head-of-line blocking. Even if packet 3 arrived fine, the application can't see it until packet 2 is resolved.

What UDP Does

UDP guarantees:
  ✓ Error detection (checksum)
  ✗ Delivery
  ✗ Order
  ✗ Flow control
  ✗ Congestion control

UDP just sends packets. No handshake. No ACKs. No retransmission. The packet either arrives or it doesn't, and you find out about it only by building your own detection.

import socket

# UDP send — fire and forget
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(b"hello", ("192.168.1.1", 5000))
# No guarantee this arrived. No waiting.

# TCP send — will retry until ACK or connection drops
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("192.168.1.1", 5000))
sock.send(b"hello")
# Guaranteed to arrive or raise an exception

When UDP Wins

Real-Time Audio and Video

Video call: you're sending 30 frames per second
Frame 47 is lost.
By the time you could retransmit and receive it,
you're on frame 52.

Frame 47 is useless. Drop it and continue.

Retransmitting stale video frames is worse than showing a brief glitch. UDP lets you skip ahead; TCP would stall waiting for the retransmit.

Games

Player position update: "Player is at (100, 200)"
Next update 16ms later: "Player is at (103, 202)"

If update 1 is lost, update 2 has the correct position anyway.
Retransmitting update 1 wastes bandwidth and causes jitter.

Games use UDP and implement their own selective retransmission only for critical state (player health, inventory changes), not for high-frequency position updates.

DNS

DNS request: "What's the IP for example.com?"
UDP packet: 64 bytes

If no response in 100ms → send again
If still no response → try another server

No connection setup overhead. Queries are stateless.

DNS responses fit in a single UDP packet (or a few). The client implements its own retry logic. TCP overhead would slow every domain lookup.

Multicast and Broadcast

UDP supports sending one packet to multiple recipients simultaneously. TCP is point-to-point only — you'd have to send N copies for N recipients.

When TCP Wins

Everything Else

File transfers, HTTP, databases, SSH, email — any protocol where you need the full contents, in order, without corruption. The reliability overhead is worth it because losing any part of the data breaks the whole thing.

QUIC: The Modern Middle Ground

HTTP/3 runs on QUIC, which runs on UDP but re-implements TCP's reliability features with improvements:

QUIC fixes TCP's head-of-line blocking problem:
  - Multiple streams within one connection
  - Stream 2 being lost doesn't block stream 3
  - Built-in TLS (0-RTT connection possible)
  - Connection migrates when IP changes (mobile networks)

QUIC is why "just use UDP and implement your own reliability" is sometimes actually the right call — except you're implementing QUIC, not UDP from scratch.

Practical Code Examples

UDP Server (Python)

import socket

def udp_server(host='0.0.0.0', port=5000):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((host, port))
    print(f"UDP server on {host}:{port}")

    while True:
        data, addr = sock.recvfrom(1024)  # Max 1024 bytes per packet
        print(f"Received {data!r} from {addr}")
        # No connection state — reply directly to sender's address
        sock.sendto(b"pong", addr)

udp_server()

TCP Server (Python)

import socket

def tcp_server(host='0.0.0.0', port=5000):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((host, port))
    sock.listen(5)
    print(f"TCP server on {host}:{port}")

    while True:
        conn, addr = sock.accept()  # Blocks until client connects
        with conn:
            print(f"Connection from {addr}")
            data = conn.recv(1024)
            conn.sendall(b"pong")
        # Connection closes when context exits

tcp_server()

Implementing Simple Reliability Over UDP

// Simple request-response with timeout and retry
func sendWithRetry(conn *net.UDPConn, addr *net.UDPAddr, msg []byte) ([]byte, error) {
    const maxRetries = 3
    const timeout = 100 * time.Millisecond

    for attempt := 0; attempt < maxRetries; attempt++ {
        conn.WriteTo(msg, addr)
        conn.SetReadDeadline(time.Now().Add(timeout))

        buf := make([]byte, 1024)
        n, _, err := conn.ReadFrom(buf)
        if err == nil {
            return buf[:n], nil
        }

        // Timeout — retry with backoff
        timeout *= 2
    }

    return nil, fmt.Errorf("no response after %d attempts", maxRetries)
}

The Quick Decision

Need all data intact (file, HTTP, database)?   → TCP
Real-time, tolerance for some loss (video)?    → UDP
Simple query-response, small packets (DNS)?    → UDP
Can't tolerate any loss, need ordering?        → TCP
Building multiplayer game?                     → UDP (probably)
Building anything else?                        → TCP, probably

The Bottom Line

TCP trades latency for reliability. UDP trades reliability for latency. The right choice depends on whether lost data is catastrophic or ignorable.

The rules:

  • Default to TCP — it handles most cases correctly with no extra work
  • Use UDP when you control retransmission logic or when stale data is worse than lost data
  • Real-time audio/video almost always belongs on UDP
  • DNS, NTP, and game state updates are the classic UDP use cases
  • QUIC is TCP's reliability on UDP's transport — it's the direction the web is heading

When in doubt: TCP. When latency is the bottleneck and you understand the failure modes: UDP.