Networking System Design

TCP vs UDP: Protocol Trade-offs for Modern Backend Systems

TCP vs UDP explains when you want reliable streams, when datagrams win, and how loss, latency, and head-of-line blocking show up in live production paths.

Executive Summary: TCP and UDP make opposite bets on who repairs loss — the kernel or your application — and picking the wrong one means retries stack up, tails grow, and a small network blip turns into a full outage that shows up as timeouts, not a protocol setting. This guide covers where head-of-line blocking creeps into a TCP-based design, when UDP’s looser guarantees are actually the right trade for latency-sensitive traffic, and how to decide deliberately when the default socket someone chose years ago is quietly wrong for the workload today.

TCP vs UDP is the choice that decides who repairs loss, you or the kernel. When you pick the wrong one, retries stack, tails grow, and a small blip becomes an outage. You feel it as timeouts, not as a protocol flag. Still, many services copy a default socket and never revisit it.

TCP gives you a byte stream that arrives in order, or it fails the connection. UDP gives you messages that may vanish, duplicate, or pass each other. Because those promises differ, the rest of the stack must match them. Also, a fast path on a laptop can fall apart once loss and many clients show up.

What each protocol is and why it fails

RFC 9293 specifies TCP as a reliable, ordered stream between two ends. RFC 768 specifies UDP as a thin datagram header with ports and a checksum. When people say UDP is faster, they often mean it skips the handshake and the retransmit work. Then they are surprised when the app must build that work itself.

TCP fails in production when short calls pay a full handshake every time. If you also wait on slow start, a tiny RPC spends more time in the kernel than in your handler. Also, one lost packet can stall every byte behind it on that connection. That stall is head of line blocking, and it hits multiplexed calls hard.

UDP fails when you pretend loss does not exist. A common mistake I have seen is a request path that sends one datagram and waits with a long timeout. Then a single drop looks like a dead server. After a few retries with no backoff, you melt the peer and your own thread pool.

RFC 8085 is the usage guide for UDP, and it is blunt about congestion. If your UDP sender does not slow down, it will crowd out TCP flows that do. Therefore a metrics firehose or a discovery protocol still needs a rate cap. So UDP is not a free pass around fairness.

How the implementation behaves

A TCP connect starts with a three way handshake before the first payload byte. First, the client sends SYN. Next, the server replies with SYN and ACK.

Finally, the client ACKs, and only then can data flow. When the server is far away, that round trip dominates a call that would have been short.

After the handshake, TCP grows a congestion window. While the window is small, the sender dribbles bytes even if your buffer is full. Then loss cuts the window and the send rate drops. Because this is per connection, a fresh socket after every request repeats slow start all day.

Bytes versus messages

TCP does not preserve your write boundaries. If you write two requests, the peer may read them as one blob or as several pieces. When you ignore that, parsers drift and you see rare corrupt calls. Also, a partial write is normal, so you loop until the buffer is empty or the deadline hits.

UDP keeps the boundary. One send is one recv, if it arrives, and it arrives whole or not at all. Still, the datagram can be bigger than the path allows, and then it is dropped or split. If you send large payloads, test the real MTU, not the loopback device.

State the kernel keeps

Each TCP connection holds state on both ends and often on a firewall in the middle. TIME_WAIT on the side that closes first can last for minutes. If you churn connections, you run out of ephemeral ports and new calls fail with a local error. Then the app looks healthy and the host cannot dial out.

UDP holds almost no per peer state in the kernel unless you add it. However, a busy socket can still drop packets in the receive buffer. When the buffer is small, bursts vanish before your process wakes. Also, one shared socket needs a clear rule for who demuxes replies, or you will mix them.

Trade-offs you should pick on purpose

Use TCP when the payload is a stream or an RPC that must arrive intact. Use UDP when loss is acceptable, or when you will add your own repair above it. Also, use UDP when a handshake on every tiny query is the dominant cost, as with DNS. If you need reliable streams without TCP head of line blocking, look at QUIC, which rides on UDP.

First choice.When it fits.If it breaks.
TCP with reuse.Use it when calls must arrive in order and intact.One loss stalls every stream on that connection.
Plain UDP.Use it when a lost message is cheap to skip.You must cap the rate or you harm other flows.
UDP plus your own repair.Use it when you control both ends and can resend.A weak resend design becomes a worse TCP.
QUIC on UDP.Use it when you want independent streams and a handshake that can resume.Some networks block UDP, so you still need a TCP fallback.

Internal RPC is usually TCP or QUIC, not raw UDP. gRPC and REST both sit on reliable transports, because a missing byte is a failed call. Meanwhile, DNS for backend engineers is the classic UDP path with a TCP retry when the reply is truncated. If you debug a lookup stall, check both transports before you blame the zone.

HTTP/2 and HTTP/3 make this trade-off concrete. HTTP/2 multiplexes streams on one TCP connection, so one loss stalls them all. HTTP/3 moves those streams onto QUIC, so one loss stalls one stream. Therefore the protocol choice is really a TCP versus UDP choice with extra rules on top.

Pitfalls and failure modes

Nagle and delayed ACK can add a quiet delay to small writes. If you send a tiny request and wait for a reply, the kernel may hold your bytes for a timer. Then p99 jumps by a fraction of a second and the profiler shows idle time. Also, turning Nagle off without batching can flood the path with tiny packets.

Middleboxes drop UDP they do not understand, or they time out idle TCP flows. When a NAT mapping dies, the next packet vanishes or the peer sends a reset. Still, your client may sit until its own read timeout. Because of that, keepalives need to be shorter than the path timeout, not longer.

A partition makes the two protocols fail in different shapes. TCP may retry until the user deadline, and UDP may simply lose the datagram. Read network partitions with that split in mind. If both sides keep sending on a black holed path, you will fill buffers and then shed good traffic too.

  1. First, confirm whether the call is a new connection or a reused one.
  2. Next, check retransmission and timeout counters before you scale the service.
  3. Then look for ephemeral port pressure and TIME_WAIT when connect fails locally.
  4. After that, capture one loss event and see whether every stream stalled or only one message.
  5. Finally, verify that UDP sends have a rate cap and a deadline shorter than the user request.

In my experience, teams add retries at the client, the mesh, and the server. While each layer is polite alone, together they multiply load during loss. So pick one retry owner and make the others fail fast. Also, bound the total time so a dead peer cannot hold a worker.

A socket setup you can copy

The snippet opens a TCP socket with a deadline and keepalive. It is a baseline for an internal client, not a full pool. Also, the timeout stops a black hole from pinning a thread. If you reuse the socket, you avoid a new handshake on every call.

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
sock.settimeout(2)
sock.connect(("payments.internal", 8443))
sock.sendall(b"ping")
data = sock.recv(64)

For UDP, set a deadline the same way, and cap how many times you resend. Because there is no kernel retransmit, silence means you choose the next step. Then back off, or you will sync every client onto the same instant. Also, keep the payload small enough that one datagram fits the path.

Do not share one blocking socket across threads without a lock or a design for it. When two threads read the same TCP stream, bytes split at random. If you need many calls at once, use a pool or a multiplexed protocol on top. Still, a pool of thousands of idle connections will tax file descriptors and NAT tables.

Performance, scale, and cost

Connection setup is the first cost people undercount. In an illustrative production range, a cross region handshake can cost one to two round trips before any useful byte. If each user request opens a new socket, that tax lands on the critical path. Therefore pools, keep alive, and protocol multiplexing exist.

Throughput on TCP is tied to loss and delay, not only to link size. A small loss rate on a long path can cut the window and leave bandwidth idle. Meanwhile, UDP can push more packets per second until the receiver or the network drops them. Then your goodput falls while CPU on both ends climbs.

Scale pain shows up as port exhaustion, firewall table growth, and retry storms. A fleet that reconnects together after a blip will SYN flood its own dependency. Also, UDP fans that ignore loss will retry in lockstep. So jitter the timers, and shed load when the error rate climbs.

Cost is mostly CPU, connection tracking, and failed user calls, not a line item named TCP. QUIC moves more of that work to user space, which can raise CPU while it cuts tail latency. When you compare options, measure p99 and loss, not a loopback benchmark. After the test, write down which side repaired the loss.

Key Takeaways

  • Also treat TCP as a byte stream, not as a queue of messages.
  • When you choose UDP, you own loss, reorder, duplicates, and congestion control.
  • Because a new TCP connection pays a handshake and slow start, reuse it when you can.
  • If one loss stalls every call, you are seeing head of line blocking on TCP.
  • Still cap UDP send rates so you do not crush flows that back off.
  • Therefore pick one retry layer, and keep its deadline inside the user budget.

FAQ

Is UDP always faster than TCP?

No, because speed depends on loss, distance, and whether you reuse a connection. UDP skips the handshake, so a single small query can finish sooner. However, once you add repair, order, and fairness, the gap shrinks or reverses. Then a tuned TCP pool often wins for ordinary RPC.

When should a backend use raw UDP?

Use it when a lost message is fine, such as a sample, a beep, or a cache hint. Also use it for request and reply designs that already expect a resend, such as DNS. If the call must not vanish, do not start with raw UDP. Instead, use TCP or QUIC and spend your time on the API.

Why do all streams stall together?

Because they share one TCP connection, and TCP delivers bytes in order. When one packet drops, later packets wait even if they belong to other calls. Also, growing the pool can isolate the stall to fewer calls. After that, HTTP/3 or QUIC can remove that shared stall if UDP is allowed.

What should I check first in an incident?

First, check whether connects fail locally from port pressure or fail remotely from loss. Next, look at retransmits and receive drops. Then see if retries from several layers fired at once. Finally, confirm keepalive and idle timeouts match the path, not a lab default.

Pick one internal call that is slow or flaky and name the transport. Measure handshake time, loss, and whether one drop stalls more than one call. After that, reuse connections or move that path to a transport that matches the failure you saw. If you do this on the worst client first, the next incident will have a protocol hypothesis instead of a guess.

Last updated on 14 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *