TCP and UDP compared: connections, reliability, ordering, flow and congestion control, head-of-line blocking, typical uses, and QUIC/HTTP/3 in .NET.
Use TCP for most APIs, databases and file transfer. Use UDP for DNS, real-time media, games, metrics (StatsD) and as the base for QUIC. HTTP/3 runs on QUIC over UDP so it can combine TLS 1.3 with the transport handshake, avoid TCP's head-of-line blocking between streams, and survive network changes.
| TCP | UDP | |
|---|---|---|
| Connection | Three-way handshake | None |
| Unit | Byte stream (no message boundaries) | Datagrams (boundaries kept) |
| Reliability | Retransmits lost segments | None; packets may be lost |
| Ordering | Guaranteed | Not guaranteed |
| Duplicates | Removed | Possible |
| Flow/congestion control | Built in | None (app must be polite) |
| Header | 20–60 bytes | 8 bytes |
| Typical uses | HTTP/1.1 and HTTP/2, SQL, SMTP, SSH, gRPC | DNS, VoIP, video, games, StatsD, QUIC/HTTP/3 |
| .NET | TcpClient, Socket (Stream), NetworkStream |
UdpClient, Socket (Dgram) |
HTTP/2 over TCP: streams A, B, C share one TCP connection
packets: A1 B1 C1 [A2 lost] B2 C2
TCP delivers in order, so B2 and C2 wait until A2 is retransmitted → all streams stall
HTTP/3 over QUIC (UDP): each stream ordered independently
A waits for A2; B2 and C2 are delivered immediately
Changing TCP itself is almost impossible because it lives in every OS kernel and middlebox. UDP passes through networks untouched, so QUIC implements reliability, congestion control and streams in user space on top of it, with TLS 1.3 built in. Benefits: 1-RTT setup (0-RTT on resumption), no cross-stream head-of-line blocking, and connection migration when a phone switches from Wi-Fi to mobile data.
A telemetry agent sent metrics to a collector over TCP, one connection per agent. During a collector slowdown, TCP back-pressure blocked the agent's send calls, and because the agent sent synchronously from request threads, the application slowed down too. We switched to UDP StatsD-style fire-and-forget for counters, accepting rare loss, and kept TCP (OTLP over gRPC with batching and a bounded queue) for traces. The app was no longer coupled to the collector's health.
Checks transport-layer fundamentals and the ability to choose protocols based on requirements.
Green flags
Red flags
Client Server
│── SYN seq=x ───────────────────► │ LISTEN
│◄── SYN-ACK seq=y ack=x+1 ────────│
│── ACK ack=y+1 ──────────────────► │ ESTABLISHED
│── data ─────────────────────────► │
│◄── ACK ───────────────────────── │
│── FIN ─────────────────────────► │ (graceful close, TIME_WAIT on the closer)
Socket.NoDelay = true disables it (Kestrel and HttpClient already do this).sender: Send("HELLO") Send("WORLD")
receiver may get: "HELLOWOR" then "LD" or "HELLOWORLD" or "HEL" "LOWORLD"
Your protocol must frame messages (length prefix or delimiter). HTTP, gRPC and WebSockets do this for you.
HttpClient support HTTP/3 via System.Net.Quic, which uses msquic (built into Windows 11 / Server 2022; on Linux install the libmsquic package).Alt-Svc header, then try QUIC on UDP 443, falling back to HTTP/2 if UDP is blocked.HttpClient needs request.Version = HttpVersion.Version30 and a version policy to try it.UdpClient listener and send 1,000 numbered datagrams from another app over Wi-Fi; count how many arrive and whether order changes.curl --http3 -I https://cloudflare.com (curl built with HTTP/3) and inspect the alt-svc header with curl -I.// UDP: fire-and-forget metric (StatsD-style). No connection, no delivery guarantee.
using var udp = new UdpClient();
udp.Connect("metrics.internal", 8125); // sets default remote; no handshake
byte[] payload = Encoding.ASCII.GetBytes("orders.created:1|c");
await udp.SendAsync(payload); // returns immediately even if nobody is listening
// TCP: framed messages over a stream (length prefix), because TCP has no message boundaries
using var client = new TcpClient { NoDelay = true };
await client.ConnectAsync("ledger.internal", 9000, ct);
await using var stream = client.GetStream();
static async Task SendMessageAsync(Stream s, ReadOnlyMemory<byte> body, CancellationToken ct)
{
var header = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(header, body.Length);
await s.WriteAsync(header, ct);
await s.WriteAsync(body, ct);
}
static async Task<byte[]> ReadMessageAsync(Stream s, CancellationToken ct)
{
var header = new byte[4];
await s.ReadExactlyAsync(header, ct);
var body = new byte[BinaryPrimitives.ReadInt32BigEndian(header)];
await s.ReadExactlyAsync(body, ct);
return body;
}
// HttpClient trying HTTP/3 with fallback
using var http = new HttpClient
{
DefaultRequestVersion = HttpVersion.Version30,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
using var response = await http.GetAsync("https://example.com/", ct);
Console.WriteLine(response.Version); // 3.0 if QUIC worked, else 2.0 or 1.1
Mistake: Saying UDP is simply "faster". Why it is wrong: It has less overhead and no handshake, but the application then owns loss, ordering and pacing, and blasting UDP without congestion control hurts other traffic. Instead: Choose UDP when late data is useless or loss is acceptable, and TCP or QUIC when every byte matters.
Mistake: Using TCP for hard real-time data. Why it is wrong: A single lost packet blocks newer data until it is retransmitted, so voice, video or game state arrives late instead of being skipped. Instead: Use UDP-based protocols (RTP, WebRTC, QUIC datagrams) that can drop or replace stale data.
Mistake: Treating TCP reads as messages.
Why it is wrong: One Send can arrive as several reads, or several sends as one read.
Instead: Frame messages with a length prefix or delimiter and read with ReadExactlyAsync.
Mistake: Sending large UDP datagrams. Why it is wrong: Anything over the path MTU fragments at the IP layer, and losing any fragment loses the whole datagram; many firewalls also drop fragments. Instead: Keep payloads under about 1,200 to 1,400 bytes.
Mistake: Assuming HTTP/3 always works.
Why it is wrong: Corporate firewalls and some load balancers block or rate-limit UDP 443, and Linux hosts need libmsquic installed.
Instead: Use HttpVersionPolicy.RequestVersionOrLower so clients fall back to HTTP/2, and verify UDP 443 end to end.
The real trade-off is guarantees versus coupling and latency. TCP's guarantees come with back-pressure and head-of-line blocking, which couple sender to receiver and one stream to another.
| Need | Protocol | Why |
|---|---|---|
| Internal APIs, gRPC | HTTP/2 over TCP | Mature, multiplexed, well supported by proxies |
| Mobile or lossy clients | HTTP/3 over QUIC | Per-stream recovery, faster setup, connection migration |
| Real-time media, games | UDP (RTP, WebRTC) | Late data is worthless; skip rather than wait |
| Loss-tolerant metrics | UDP (StatsD) | Never blocks the app when the collector is slow |
| Traces and logs | TCP with a bounded background exporter | Need completeness, but must not block requests |
Architecturally, protocol choice shapes failure behaviour: TCP back-pressure can spread a slow consumer's problem into your request threads, while UDP pushes the decision about loss into your code. I keep telemetry off request paths, treat HTTP/3 as an optimisation with fallback, and measure before claiming benefits. Most internal service traffic is still best served by HTTP/2 over well-reused TCP connections.
HTTP is a request/response protocol: method + path + headers (+ body) in, status + headers (+ body) out.
GET/HEAD/OPTIONS are safe; GET, PUT, DELETE are idempotent (repeating has the same effect); POST and usually PATCH are not, so retries need an idempotency key.Content-Type, Accept, Authorization, Cache-Control, ETag/If-None-Match, Retry-After, traceparent.Two different limits produce similar errors:
EMFILE, "Too many open files"): the process hit its ulimit -n. Something is leaking files or sockets.EADDRNOTAVAIL, "Cannot assign requested address"): too many outbound connections to the same destination, usually closed sockets stuck in TIME_WAIT for 60 s.The classic .NET cause is new HttpClient() per request. Fix it with a shared HttpClient using SocketsHttpHandler { PooledConnectionLifetime = ... }, or IHttpClientFactory. Diagnose with ls /proc/<pid>/fd | wc -l, lsof -p, ss -s and ss -tan state time-wait.
A port is a 16-bit number (0 to 65535) that lets one IP address host many services. A socket is the OS object for one endpoint: a listening socket bound to IP:port, and a connected socket for each accepted connection, identified by the 4-tuple (client IP, client port, server IP, server port).
The Docker problem is almost always binding: the app listens on localhost (127.0.0.1) inside the container, which is unreachable from outside its network namespace, or it listens on a different port from the one you mapped. The official .NET 10 images set ASPNETCORE_HTTP_PORTS=8080, which binds all interfaces on 8080 (the default since .NET 8), so map it with -p 5000:8080 and do not override it with a localhost URL.
How to tell it (STAR)
**S:** Our [checkout API] slowed down every time the [metrics collector] had problems. **T:** I needed to decouple the app from its telemetry. **A:** A dump showed request threads blocked writing metrics over TCP, because TCP back-pressure stalls the sender when the receiver is slow. I moved counters to UDP, which never blocks, and traces to a bounded background exporter. **R:** A later collector outage had [zero] effect on checkout latency, and we only lost a small number of metrics.
Last updated 23 Oct 2026.