Module 1 · Network Foundations · Lesson 2 of 18
TCP vs UDP for .NET Developers: Sockets, Handshakes, Framing and TIME_WAIT
Learning outcome
You can explain how TCP turns an unreliable network into a reliable, ordered byte stream, and what that costs. You can walk through the three-way handshake, flow control, congestion control and the four-way close, say why TIME_WAIT exists and which side holds it, and read the TCP states you see in ss or netstat. You can say when UDP is the better choice, why QUIC (HTTP/3) runs on UDP, and why "one write is one read" is false on TCP. The code example proves that last point and fixes it with length-prefix framing.
Intuition
IP is a postcard service: each card is carried on its own, may be lost, duplicated or arrive out of order, and nobody tells you. TCP is a pair of clerks who turn postcards into a conversation. They number every byte, send receipts (ACKs), resend whatever is not acknowledged, throw away duplicates and hand bytes over strictly in order. They also pace themselves so neither the receiver nor the network is swamped. UDP is the postcard service with a port number on it: fast and simple, and every guarantee is your problem.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: SYN (seq = x)
S->>C: SYN-ACK (seq = y, ack = x+1)
C->>S: ACK (ack = y+1)
Note over C,S: ESTABLISHED: 1 RTT spent
C->>S: data (numbered bytes)
S->>C: ACK + window size
C->>S: FIN
S->>C: ACK
S->>C: FIN
C->>S: ACK
Note over C: TIME_WAIT for 2 x MSLDeep dive
What a socket is
A socket is an OS kernel object your process holds a handle to. It has a send buffer, a receive buffer and TCP state, and it is keyed by the 4-tuple. await stream.WriteAsync(...) copies bytes into the kernel send buffer and usually completes before the other side has received anything. Reading takes bytes the kernel has already received. A listening socket only accepts; each accepted client gets its own new socket that shares the server port.
TCP: reliability, ordering, flow control
- Handshake: SYN, SYN-ACK, ACK. Both sides choose initial sequence numbers. This costs one round trip before any data flows (TCP Fast Open exists but is rarely used across the internet).
- Reliability: every byte has a sequence number. Unacknowledged data is retransmitted after a timeout that adapts to the measured RTT. Duplicates are discarded and gaps are filled before data reaches the application.
- Flow control: every ACK advertises a receive window ("I have room for N more bytes"). If your code stops reading a response stream, the window drops to zero and the sender pauses. That is backpressure at the TCP level.
- Congestion control: TCP does not know the path's capacity, so it probes. Slow start begins with a small congestion window (about 10 segments, roughly 14 KB) and roughly doubles it each round trip until loss or delay signals the limit. A brand-new connection cannot use the full bandwidth for its first few RTTs. A warm pooled connection has an open window, so pooling buys throughput as well as latency.
- Nagle and delayed ACK: Nagle's algorithm holds back small writes while data is unacknowledged; delayed ACK waits up to tens of milliseconds to piggyback an ACK. Together they cause mysterious fixed 40 ms stalls on small request/response traffic. Kestrel and
SocketsHttpHandlersetNoDelayby default, so you mostly meet this with raw sockets.
TCP is a stream, not a message queue
TCP delivers bytes in order, but it does not preserve the boundaries of your writes. Two writes can arrive in one read, and one write can arrive in several reads. Any protocol on top of TCP needs framing: a length prefix (gRPC, many binary protocols), a delimiter (newline-delimited JSON, Redis RESP), or a header that states the length (HTTP Content-Length and chunked encoding). With a nonempty buffer, NetworkStream.ReadAsync returns available bytes, not "a message", or zero when the peer has gracefully closed and no data remains. Use ReadExactlyAsync (or System.IO.Pipelines) to read a known number of bytes.
Closing and TIME_WAIT
A normal close exchanges FINs and acknowledgments in both directions; an ACK and FIN can share a segment. The active closer normally enters TIME_WAIT for twice the maximum segment lifetime; simultaneous close can put both sides there. On Linux that is 60 seconds, fixed. On Windows, check the version and TcpTimedWaitDelay configuration rather than assuming a universal 120-second default. TIME_WAIT is a correctness feature: it keeps the 4-tuple reserved so delayed segments from the old connection cannot be mistaken for data on a new connection that reuses the same ports. The cost is retained state and restrictions on reusing that same 4-tuple; the source port can still be usable for other destinations, subject to OS allocation rules. A client that opens and closes 200 connections per second to one destination holds about 12,000 ports in TIME_WAIT on Linux. That arithmetic is behind socket exhaustion (Lesson 8).
The states you will actually meet
| State | Meaning | Typical cause when you see many |
|---|---|---|
LISTEN | waiting for connections | normal on servers |
SYN-SENT | connect sent, no answer yet | firewall dropping packets, wrong IP, server down |
ESTABLISHED | open connection | normal; a huge number may mean a leak or no pooling limit |
TIME-WAIT | this side closed first and is waiting | a new connection per request (new HttpClient() per call) |
CLOSE-WAIT | the peer closed, your app has not | your code never disposed a response or stream: a real bug |
FIN-WAIT-2 | you closed, the peer has not | slow or broken peer |
Keep-alive: two different things
HTTP keep-alive (persistent connections) is the default since HTTP/1.1: the connection stays open after a response for the next request, which is what pooling uses. TCP keep-alive is an optional probe on an idle connection to detect a dead peer and to stop NAT devices and load balancers from dropping idle flows. Azure Load Balancer's default idle timeout is 4 minutes. A long-idle connection through it can be silently dropped, and the next write fails with a reset.
UDP
UDP sends independent datagrams. Each one arrives whole or not at all; there is no connection, ordering, retransmission or congestion control. Message boundaries are preserved, and the maximum payload over IPv4 is 65,507 bytes (in practice you keep datagrams under the path MTU, about 1,200 to 1,400 bytes, to avoid fragmentation). Choose UDP when late data is worthless or when you build your own reliability: DNS queries, voice and video, game state, metrics (StatsD), service discovery, and QUIC.
Why HTTP/3 uses UDP
QUIC (RFC 9000) implements reliability, congestion control and TLS 1.3 itself, in user space, on top of UDP. Middleboxes on the internet only reliably pass TCP and UDP, so a new transport protocol had to look like UDP. QUIC streams are independent, so one lost packet stalls only its own stream. It also merges the transport and TLS handshakes into one round trip and supports connection migration when a phone changes networks.
Code example: framing a TCP stream, and UDP datagrams
The client sends one logical message in two writes 100 ms apart. A naive reader sees two "messages". Then a 4-byte length prefix with ReadExactlyAsync restores the boundaries. Finally UDP shows whole datagrams and the size limit.
using System.Net;
using System.Net.Sockets;
using System.Buffers.Binary;
using System.Text;
// Part 1: TCP is a byte stream. One Write is not one Read.
using (var listener = new TcpListener(IPAddress.Loopback, 0))
{
listener.Start();
var ep = (IPEndPoint)listener.LocalEndpoint;
var serverTask = Task.Run(async () =>
{
using var conn = await listener.AcceptTcpClientAsync();
var stream = conn.GetStream();
var buffer = new byte[1024];
int n, reads = 0;
while ((n = await stream.ReadAsync(buffer)) > 0)
Console.WriteLine($" naive read {++reads}: \"{Encoding.UTF8.GetString(buffer, 0, n)}\"");
});
using (var client = new TcpClient())
{
await client.ConnectAsync(ep);
var s = client.GetStream();
Console.WriteLine("Client sends one message \"HELLO\" in two writes, 100 ms apart:");
await s.WriteAsync("HEL"u8.ToArray());
await Task.Delay(100);
await s.WriteAsync("LO"u8.ToArray());
} // dispose = FIN
await serverTask;
}
// Part 2: length-prefix framing restores message boundaries.
using (var listener = new TcpListener(IPAddress.Loopback, 0))
{
listener.Start();
var ep = (IPEndPoint)listener.LocalEndpoint;
var serverTask = Task.Run(async () =>
{
using var conn = await listener.AcceptTcpClientAsync();
var stream = conn.GetStream();
var header = new byte[4];
while (true)
{
try { await stream.ReadExactlyAsync(header); }
catch (EndOfStreamException) { break; }
var body = new byte[BinaryPrimitives.ReadInt32BigEndian(header)];
await stream.ReadExactlyAsync(body); // waits until the whole message has arrived
Console.WriteLine($" framed message: \"{Encoding.UTF8.GetString(body)}\"");
}
});
using (var client = new TcpClient())
{
await client.ConnectAsync(ep);
var s = client.GetStream();
Console.WriteLine("Client sends \"HELLO\" and \"WORLD\" with a 4-byte length prefix, split across writes:");
foreach (var msg in new[] { "HELLO", "WORLD" })
{
var bytes = Encoding.UTF8.GetBytes(msg);
var frame = new byte[4 + bytes.Length];
BinaryPrimitives.WriteInt32BigEndian(frame, bytes.Length);
bytes.CopyTo(frame, 4);
await s.WriteAsync(frame.AsMemory(0, 6)); // header + first 2 bytes
await Task.Delay(50);
await s.WriteAsync(frame.AsMemory(6));
}
}
await serverTask;
}
// Part 3: UDP keeps datagram boundaries, but has no connection, ordering or retransmission.
using (var receiver = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)))
using (var sender = new UdpClient(AddressFamily.InterNetwork))
{
var ep = (IPEndPoint)receiver.Client.LocalEndPoint!;
Console.WriteLine("UDP: three datagrams sent, each received whole:");
foreach (var msg in new[] { "ping-1", "ping-2", "ping-3" })
await sender.SendAsync(Encoding.UTF8.GetBytes(msg), ep);
for (var i = 0; i < 3; i++)
{
var r = await receiver.ReceiveAsync();
Console.WriteLine($" datagram of {r.Buffer.Length} bytes: \"{Encoding.UTF8.GetString(r.Buffer)}\"");
}
try
{
await sender.SendAsync(new byte[70_000], ep); // max UDP payload over IPv4 is 65,507 bytes
}
catch (SocketException ex)
{
Console.WriteLine($"UDP datagram of 70,000 bytes: SocketException {ex.SocketErrorCode}");
}
}Output:
Client sends one message "HELLO" in two writes, 100 ms apart: naive read 1: "HEL" naive read 2: "LO" Client sends "HELLO" and "WORLD" with a 4-byte length prefix, split across writes: framed message: "HELLO" framed message: "WORLD" UDP: three datagrams sent, each received whole: datagram of 6 bytes: "ping-1" datagram of 6 bytes: "ping-2" datagram of 6 bytes: "ping-3" UDP datagram of 70,000 bytes: SocketException MessageSize
What to notice:
- The naive reader printed
HELandLO: TCP delivered the bytes correctly and in order, but not as one message. - The framed reader gets whole messages even though each frame was split across two writes.
- UDP kept the boundaries of each datagram, and a 70,000-byte datagram fails with
MessageSizeinstead of being split for you.
Failure modes
- Treating
ReadAsyncas "read a message". Works on loopback in tests, fails under load or over a real network. Always frame. - Ignoring
CLOSE_WAIT. It grows until restart and comes back. Find the undisposed response or stream. - "Fixing" TIME_WAIT with kernel tweaks.
tcp_tw_reuseand shorter timeouts hide the symptom. Reuse connections instead. - UDP without a plan for loss. Assume datagrams can be dropped, duplicated and reordered, and keep them under the MTU.
- Long idle connections through load balancers. Enable keep-alive probes or limit idle time below the balancer's idle timeout.
Interview angle
"TCP vs UDP?" A strong answer is about trade-offs, not a list: "TCP gives me an ordered, reliable byte stream with flow and congestion control, paid for with a handshake round trip, head-of-line blocking and per-connection state. UDP gives me independent datagrams with no guarantees, so I use it when late data is useless or when I build reliability myself, as QUIC does for HTTP/3. Most .NET backend traffic is HTTP over TCP, and the TCP details that bite are slow start on new connections and TIME_WAIT when a client opens a connection per request." Then mention framing if they ask about raw sockets.
Practice questions
- Which side of a TCP connection enters TIME_WAIT, and for how long on Linux?
Answer: Normally the active closer (first FIN); both sides can enter TIME_WAIT on simultaneous close. Linux keeps it for 60 seconds.
- A service shows 3,000 sockets in
CLOSE_WAITto a downstream API. What is the likely bug?
Answer: The peer closed but the application never closed its side, usually because HttpResponseMessage or a response stream was not disposed (often with ResponseHeadersRead).
- Why does a brand-new TCP connection transfer a 1 MB response slower than a warm one?
Answer: Slow start: the congestion window starts small and grows each RTT. A warm connection already has a larger window, and skips the handshakes.
- Your TCP server sometimes parses half a JSON message. Why, and what is the fix?
Answer: TCP does not preserve write boundaries. Add framing (length prefix or delimiter) and read the full frame with ReadExactlyAsync or Pipelines.
- Give two reasons QUIC runs on UDP instead of being a new IP protocol.
Answer: Middleboxes and firewalls only pass TCP and UDP reliably, and implementing it in user space over UDP lets it ship and evolve without OS kernel changes.
Revision checklist
- Handshake: SYN, SYN-ACK, ACK = 1 RTT.
- Reliability via sequence numbers, ACKs, retransmission; flow control via the window.
- Slow start makes new connections slow; pooling keeps the window open.
- TCP is a stream: frame your messages.
- TIME_WAIT on the closer (60 s Linux); CLOSE_WAIT means your code did not close.
- UDP: datagrams, no guarantees; QUIC builds TCP-like features on UDP.
Related on TechInterviewNotes
- Next lesson: DNS for .NET Developers: TTL, Caching and Stale Connections
- Previous lesson: How a Request Crosses the Network
Analogy
Registered mail vs postcards
TCP is registered mail with numbered pages and signed receipts: nothing is lost, pages arrive in order, but you wait for the receipts and the clerk keeps the file open for a while after you hang up. UDP is a postcard: cheap and immediate, and if it gets lost nobody tells you.
Quick reference
TCP and UDP facts
- Handshake 1 RTT; TLS 1.3 adds 1 more.
- Slow start: about 10 segments initially, doubles per RTT.
- Frame TCP messages;
ReadExactlyAsyncfor fixed lengths. - TIME_WAIT: closer, 60 s on Linux. CLOSE_WAIT: you forgot to close.
- Azure LB idle timeout: 4 min by default.
- UDP max payload over IPv4: 65,507 bytes; keep under the MTU.