UDP is the other half of internet transport: connectionless, unordered, lossy, and fast. Where TCP guarantees an in-order, reliable byte stream by paying for handshakes, acknowledgements, and retransmissions, UDP throws independent packets onto the wire without those guarantees. This lesson covers what UDP is good for, how UdpClient works in .NET, the size limits that affect substantial sends, broadcast and multicast, and the scenarios where using UDP is a mistake.
TCP fits most workloads, but it isn't free. Setting up a TCP connection costs at least one round trip for the SYN/SYN-ACK/ACK handshake, plus another round trip if TLS is involved. Every byte sent is acknowledged. Lost packets get retransmitted, which delays the bytes that came after them. Out-of-order packets are buffered until the missing ones arrive, a behavior called head-of-line blocking. For a file download or an HTTP request, all of this is desirable. For a live video frame, a game position update, or a metric sample, it's the wrong trade.
UDP discards all of that. A UDP packet, called a datagram, is fired off to its destination as a single self-contained unit. There's no handshake, no acknowledgement, no retry. If the datagram gets lost in the network, it's gone. If two datagrams arrive in the wrong order, the receiver sees them in the wrong order. If a datagram gets duplicated by a flaky router, the receiver sees it twice. The only guarantees UDP makes are that the datagram either arrives intact (with its checksum verified) or doesn't arrive at all. Corruption in transit is the one failure mode UDP catches; everything else, the application handles or lives with.
That sounds like a downgrade, and for most workloads it is. But three classes of work fit UDP perfectly.
The first is latency-sensitive streams where a late packet is worse than a missing one. Voice over IP, real-time video, online games, live telemetry: the next frame is already in flight, so retrying the previous one wastes bandwidth and adds jitter. Skipping a lost frame and moving on fits these cases.
The second is fire-and-forget signals where the sender doesn't care whether any particular receiver got the message. DNS queries (the classic UDP use case), service discovery broadcasts, metrics emission, log shipping where occasional loss is acceptable: the sender writes one datagram and walks away.
The third is one-to-many delivery through broadcast or multicast. TCP is strictly point-to-point. One sender talking to a thousand receivers needs a thousand TCP connections. UDP can send one datagram to a multicast group address and the network handles the fan-out.
The table below summarizes the trade-offs side by side.
| Property | TCP | UDP |
|---|---|---|
| Connection setup | Three-way handshake required | None |
| Delivery guarantee | Reliable: acks, retransmits | None: lost packets are lost |
| Ordering | Bytes arrive in send order | Datagrams may arrive in any order |
| Duplicate protection | Built-in: dupes are dropped | None: receiver may see dupes |
| Flow control | Yes (receiver window) | None: sender can overrun receiver |
| Congestion control | Yes (slow start, fast recovery) | None by default |
| Message boundaries | None: it's a byte stream | Preserved: one send = one datagram |
| Max payload per write | Unlimited (it's a stream) | ~65,507 bytes, practically ~1472 bytes |
| Header overhead | 20 bytes minimum | 8 bytes |
| One-to-many | Multiple connections needed | Broadcast and multicast supported |
| Good for | Files, web pages, APIs, anything correct | Voice, video, games, telemetry, discovery |
That last column captures the choice. If the answer to "does the receiver need every byte, in order, without duplicates?" is yes, use TCP. If it's no, UDP becomes interesting.
A picture of the difference helps. The diagram below shows a TCP exchange next to a UDP one for the same logical "send three messages and a fourth that gets lost."
On the left, TCP burns a round trip on the handshake, acknowledges every message, and retries the lost one. On the right, UDP just sends. The lost message never comes back. Message 4 arrives even though message 3 didn't. The receiver has no way to know message 3 ever existed.
The single biggest shift coming from TCP is that UDP is message-oriented, not stream-oriented. With TCP, you write bytes into one end of a pipe and they come out the other end as bytes. The OS is free to coalesce two Send calls into one packet on the wire, or split one big Send into multiple packets, and the receiver's reads might return any chunk of the bytes the sender wrote. The receiver has to know where one logical message ends and the next begins, which is why TCP protocols define delimiters or length prefixes.
UDP is the opposite. When you call SendAsync with 200 bytes, the OS puts those 200 bytes into a single UDP datagram and sends it. When the receiver calls ReceiveAsync, it gets exactly those 200 bytes back as a single read. The boundaries are preserved. Two SendAsync calls produce two datagrams, and the receiver does two ReceiveAsync calls to read them.
That preservation is a real ergonomic win. You don't need a framing protocol on top of UDP because UDP frames things for you. The cost is that each datagram is independent: if the second one is lost, the receiver sees the first and the third with nothing in between, and there's no built-in way to tell that something is missing.
The receiver's view of a UDP flow is shown below. Each datagram is its own arrow. Losses, duplicates, and reordering are all possible.
The sender's three datagrams arrived in the order 1, 3, 2 in this example, and any of them could just as easily have been missing. Anything you want on top of that, ordering, deduplication, retry, you build yourself.
A second consequence of the message-oriented model is that UDP has no concept of a "connection." There's no Connect call that has to succeed before you can send. There's no Disconnect that tells the other side you're going away. There's no equivalent of a TCP RST telling the sender "the connection is dead." You point your UdpClient at an address, send, and that's the whole interaction. If the receiver isn't there, your packet falls into the void and you get no feedback whatsoever.
System.Net.Sockets.UdpClient is the high-level .NET wrapper around UDP socket operations. It hides the socket creation, bind, and option-setting boilerplate behind a small, friendly API. For sending, the two methods you'll use are SendAsync(byte[], int, IPEndPoint) and its overloads. The sender doesn't need to bind to a specific local port (the OS picks one), and it doesn't need to know whether anything is listening on the destination.
The example below shows a minimal sender. It encodes a string, addresses the datagram to 127.0.0.1:5005, and fires it off.
A few things are worth noticing. The constructor new UdpClient() doesn't connect anything; it just allocates a socket. IPAddress.Loopback is 127.0.0.1, which means "this machine," handy for testing. The port 5005 is arbitrary; UDP, like TCP, multiplexes traffic on a host using port numbers, and you pick one the receiver will be listening on. SendAsync returns the number of bytes actually queued for transmission, which for UDP is always the full payload or it throws.
The send completes successfully whether or not anyone is listening on the other side. If no process is bound to port 5005, the OS may generate an ICMP "port unreachable" message back to the sender, but UdpClient doesn't surface that as an exception by default. The sender's view is: "I sent 27 bytes, my job is done."
A more realistic sender publishes a stream of updates rather than one shot. The pattern stays clean because there's no connection to maintain.
Four datagrams, four SendAsync calls, no setup between them. Each call is a separate, self-contained message. The 200 ms Task.Delay is just to space the updates out for visibility; in a real feed the rate would be driven by the source data. The Async Programming section covers async/await and Task.Delay in detail.
Each SendAsync call goes through a kernel syscall and a per-packet allocation in newer .NET versions. For a handful of updates per second, the cost is invisible. For tens of thousands of datagrams per second, look at the lower-level Socket.SendAsync with Memory<byte> buffers, which avoids the array allocations.
You can optionally call client.Connect(endpoint) on a UdpClient before sending. That doesn't open a connection in the TCP sense; it just stores the destination so you can call Send(payload, length) without passing an endpoint each time. It also makes the socket reject incoming datagrams from any other address, which is sometimes useful for "request/response" UDP patterns.
After Connect, the endpoint argument disappears from SendAsync. The "association" is local to the sender; the receiver still has no idea this sender exists until a datagram arrives.
The receiver side is the mirror image. A UdpClient is bound to a specific local port, and ReceiveAsync returns the next datagram that lands there along with the sender's endpoint so you can reply if you want to.
Output (when paired with the sender above):
The constructor new UdpClient(5005) binds the socket to local port 5005 on every interface (equivalent to IPAddress.Any). ReceiveAsync returns a UdpReceiveResult, which carries the bytes (Buffer) and the sender's address (RemoteEndPoint). The sender's port is whatever the OS assigned to it when it created its UdpClient, which is why you see a high-numbered port like 54123 rather than 5005.
The receiver doesn't process bytes one at a time; each ReceiveAsync call returns exactly one datagram's worth of bytes, no more, no less. If five datagrams arrive while you're not calling ReceiveAsync, the OS buffers them in a kernel-side receive queue (within the socket's buffer limit) and hands them to you one at a time on subsequent calls. If the queue overflows, the OS silently drops the excess.
To consume a continuous feed, you loop. The pattern is almost embarrassingly simple compared to TCP.
Output (when paired with the multi-datagram sender):
The CancellationToken is what gives the receive loop a way to exit. ReceiveAsync blocks (asynchronously) until a datagram shows up or the token trips. Without a token, the loop runs forever, which is sometimes what you want for a long-running listener but rarely what you want in a unit test or a short-lived tool.
The receiver's address binding is worth a beat. new UdpClient(5005) binds to port 5005 on every network interface, equivalent to IPAddress.Any (0.0.0.0). That's usually what you want for a service. If you want to bind to a specific NIC (a multi-homed server with separate management and data networks, for example), you pass an IPEndPoint:
Output (the IP depends on the machine):
Binding to a specific IP means the socket only sees datagrams arriving on that interface. Datagrams targeting the same port on a different IP on the same machine bypass this socket entirely.
A tight while (true) await ReceiveAsync() loop on a busy listener can pin a thread pool thread for long periods if there's no async work between receives. For very high packet rates, consider batching processing onto a separate worker, or use SocketAsyncEventArgs with the lower-level Socket API to avoid per-receive allocations.
A common mistake on the receiver side is forgetting that UdpReceiveResult.Buffer is a fresh byte[] allocated for every datagram. If you're processing a million datagrams per second, that's a million array allocations and a lot of GC pressure. For low-volume cases this is fine; for high-volume cases, drop to Socket.ReceiveFromAsync with a reusable buffer.
The headline limit for a single UDP datagram is 65,507 bytes. That number comes from the 16-bit length field in the UDP header (65,535 max) minus 8 bytes of UDP header and 20 bytes of IPv4 header. So in theory you can send packets that big. In practice, you should not.
The reason is MTU, the maximum transmission unit. Every link layer has a limit on how big a frame can be before it's split. Ethernet's default MTU is 1500 bytes. Once you take out the 20-byte IPv4 header and the 8-byte UDP header, the largest UDP payload that fits in a single Ethernet frame is 1472 bytes. Anything bigger gets fragmented by the IP layer: split into multiple IP packets that the destination has to reassemble.
That sounds fine until you think about what fragmentation costs. If the receiver gets all of the fragments, it reassembles them and hands you the full datagram. If even one fragment is lost, the whole datagram is dropped and your single ReceiveAsync call sees nothing. The bigger the datagram, the more fragments, the higher the probability that at least one is lost. A 30 KB UDP datagram on a 1% loss network has roughly a 20% chance of being lost entirely (because any of its ~20 fragments going missing kills the whole thing).
Worse, some middleboxes (firewalls, NATs, certain VPNs) drop fragmented IP packets outright, which means a packet that would have arrived intact had it been smaller never arrives at all.
The diagram below shows what happens to a 4000-byte datagram on a 1500-byte-MTU link.
The application sees a single 4000-byte send and a single 4000-byte receive (if everything works). Underneath, the IP stack does three sends and three receives, and any of them going missing collapses the whole thing.
The practical rule is to keep each UDP datagram at or below 1472 bytes on a standard Ethernet network. For traffic that might cross the public internet, where smaller MTUs are common (PPPoE links, some VPNs, IPv6 with its larger headers), some applications target 1200 bytes or even 508 bytes (the IPv4 guaranteed minimum). DNS over UDP famously stayed under 512 bytes for decades for exactly this reason.
All three calls succeed locally. Whether the receiver ever sees the 4000-byte and 60,000-byte datagrams depends entirely on the network path. On loopback (127.0.0.1), they always arrive because no real network is involved. Over the public internet, the 60,000-byte one is almost certainly going to be lost.
Sending datagrams above the path MTU triggers IP fragmentation, which compounds the per-fragment loss probability and may be dropped wholesale by middleboxes. Keep datagrams at or below 1472 bytes for local-network traffic and 1200 bytes or less for traffic that crosses the public internet.
The above also assumes you're not using IPv6 or have not enabled the "don't fragment" bit. With IPv6, intermediate routers don't fragment at all; if a packet is too big for a link, the router drops it and sends an ICMPv6 "Packet Too Big" message back. The sender is then responsible for Path MTU Discovery and re-sending in smaller pieces. Most apps just stay well under the safe limit and avoid the whole topic.
| Size | What happens | Use it? |
|---|---|---|
| 508 bytes | Guaranteed by IPv4 spec to not fragment | Yes, very safe |
| 1200 bytes | Safe across most public internet paths including IPv6 and VPNs | Yes, recommended for internet |
| 1472 bytes | Fits in one Ethernet frame with default MTU | Yes, for local network |
| 1473-65,507 bytes | Fragments into multiple IP packets | Avoid: loss multiplies, middleboxes may drop |
| Above 65,507 bytes | Exceeds UDP length field | SocketException: send fails |
UDP supports two patterns TCP can't do at all: broadcast, where a single send reaches every host on the local network, and multicast, where a send reaches every host that has joined a specific group address. Both rely on special-purpose destination IP addresses and on the network's willingness to fan packets out, but the C# side stays straightforward.
Broadcast is the simpler case. You send to IPAddress.Broadcast (255.255.255.255), which the local switch floods to every device on the subnet. You also have to opt the socket into broadcasting via the EnableBroadcast property; the OS won't let you accidentally spam the network without it.
Every device on the local subnet that's listening on port 5050 receives the datagram. Routers, by default, do not forward broadcast packets, so this stays inside the LAN. That makes broadcast useful for service discovery within a single network and useless for anything spanning the public internet.
The receiver side for broadcast is exactly the same as any other UDP receive: bind to the port, call ReceiveAsync. The kernel hands you broadcast datagrams the same way it hands you any other datagram.
Multicast is more selective. Instead of "everyone on the LAN," it's "everyone who explicitly asked to hear messages on this group address." Group addresses live in the range 224.0.0.0 to 239.255.255.255. To receive multicast traffic, a host joins a group, which tells its network stack and (with IGMP) the local switch to deliver datagrams addressed to that group to this host.
Output (depends on what's sending):
The sender side for multicast looks like any other UDP send, with the group address as the destination. No JoinMulticastGroup call is needed for senders; you only join when receiving.
One publisher, one send per update. Every receiver that joined 239.0.0.42:5060 sees every update. Adding a hundred receivers doesn't change the sender's bandwidth at all; the network does the duplication. That's the appeal of multicast for live-data fan-out.
The "live stock count broadcaster" for a chain of in-store display panels is a natural fit. One backend process publishes the current count for every SKU at 1 Hz, and every panel in the store joins the group and updates its display. If a packet is lost, the panel just shows a stale count for one second before the next update overwrites it. No retries, no acknowledgements, no per-panel connection management.
The catch with multicast is that not every network forwards it. Home routers usually do. Cloud providers usually don't (you can't multicast between EC2 instances by default, for example). The protocol that controls multicast forwarding, IGMP, has to be configured on switches and routers for it to work end-to-end. Inside a managed corporate LAN it usually does. On the public internet it usually doesn't.
One send, three deliveries, no extra packets on the wire from the publisher's side. The fourth display didn't join, so the network doesn't bother forwarding it.
The single biggest mental adjustment when moving from TCP to UDP is how you find out about problems. With TCP, failures are loud. A dead connection produces a SocketException on the next read or write. A reset packet arrives with ConnectionReset. A peer closing the socket triggers a zero-length read so the other side knows the conversation is over. The transport tells you when things break.
With UDP, the transport tells you almost nothing. A datagram you sent can be lost in the network and your code sees no error at all. The receiver process can be dead, the port can be unbound, the routing can be broken, the receiver's firewall can be silently dropping your packets, and the call you made to SendAsync succeeds the same way it always does. You find out by silence: the responses you were expecting don't show up.
A small set of errors do surface as SocketException:
| Error | What it means | When it fires |
|---|---|---|
MessageSize | Payload is larger than the OS allows (typically 65,507 bytes) | At SendAsync time |
AddressAlreadyInUse | Another socket is already bound to that port | At UdpClient construction time |
NetworkUnreachable | No route to the destination network | At SendAsync time, occasionally |
HostUnreachable | Routed but the host doesn't respond at the IP layer | Sometimes, depends on ICMP feedback |
ConnectionReset | Only after Connect(): an ICMP "port unreachable" came back from a previous send | At the next ReceiveAsync or send |
The last one is sneaky. When you call Connect on a UdpClient, the OS associates the socket with that remote endpoint. If a subsequent send produces an ICMP "port unreachable" reply, the OS attaches that error to the socket and surfaces it on the next operation, even though it pertained to an earlier send. So you can get a SocketException with ConnectionReset on a ReceiveAsync call that, looking only at that call, had no obvious reason to fail. On Windows specifically, this used to break receivers entirely; the fix is to set SIO_UDP_CONNRESET to false so the OS suppresses these reset errors:
On Linux and macOS this isn't needed; the behavior is already what you'd expect. If you've ever inherited UDP code that mysteriously dies on Windows after a few minutes, this is often what's happening.
Beyond that small set, the strategy for UDP error handling is application-level, not transport-level. You define what "missing" means for your protocol and react to it.
The common patterns are:
Heartbeats. The receiver expects a datagram every N seconds. If none arrives for, say, three intervals in a row, the receiver decides the sender is dead and reacts. This is how most discovery and presence protocols work.
Sequence numbers. Each datagram carries an increasing counter. The receiver tracks the highest one it's seen and notices gaps. A gap of one is "we lost one," a gap of ten is "something is wrong." You can choose whether to react to gaps at all (live-stream behavior: just keep going) or to ask the sender to resend (which is reinventing TCP and usually a mistake).
Acknowledgements at the application layer. For RPC-style request/response, the client sends a request with a unique ID and waits for a response with the same ID, with a timeout. No response in the timeout means try again, up to a small retry limit. This is what DNS does.
The pattern you choose depends on what UDP is doing for you. A telemetry feed needs no acknowledgements; missing samples are fine. A game position update uses sequence numbers to drop stale frames and interpolate over gaps. A custom RPC needs explicit application-level acks with timeouts and retries.
Output (depends on whether the responder is up):
The request is fired off, the receive is awaited with a 500 ms cancellation. If nothing comes back in time, the function returns null. The caller decides whether to retry, escalate, or just give up.
A busy receive loop that processes nothing useful between calls can pin a thread pool thread. For very high packet rates, batch the actual work onto a separate worker so the receive loop's only job is to drain the kernel queue. Otherwise a slow handler causes the kernel buffer to overflow and datagrams start getting dropped without warning.
UDP looks attractive on a checklist: lower overhead, message-oriented, supports multicast. The temptation is to use it for anything that doesn't have an obvious file-transfer shape. Resist that. UDP is the right answer for a narrow slice of workloads, and the wrong answer for almost everything else.
Anything that needs delivery confirmation. Payments, order placement, account changes, audit-trail writes. If "the message arrived" is part of the contract, UDP is the wrong transport. You can build acknowledgements on top, but that's reinventing TCP poorly. Use TCP and an idempotent receiver.
Anything where the message order matters and you can't tolerate reordering. Event streams that drive downstream state machines, log shipping where line order is meaningful, sequenced commands. UDP datagrams arrive in whatever order the network felt like. Sorting them out after the fact requires sequence numbers, buffering, and reordering logic that ends up being more complex than just using TCP.
Anything large. Once a single logical message exceeds 1472 bytes, UDP's fragmentation penalty starts hurting. By 8-16 KB the loss rate on a real network becomes problematic. By 60 KB you might as well not bother. A 5 MB file over UDP is asking for misery.
Anything across the public internet that requires reliability. UDP traffic across the open internet faces NATs that time out faster than TCP NATs, middleboxes that drop fragmented packets, ISPs that deprioritize UDP, and firewalls that block ports the local admin didn't whitelist. For traffic that has to traverse the public internet reliably, TCP (or QUIC, which is UDP-based but rebuilds the reliability layer on top) is the answer.
Anything where you're tempted to "just retry on loss." If your retry loop has to handle out-of-order acks, dedupe, and timeouts, you've built TCP minus the years of tuning. Either accept loss and design around it (UDP) or use a transport designed for reliability (TCP, QUIC, or a higher-level protocol like HTTP/gRPC).
The honest answer for most server-to-server communication is: use TCP, use HTTP, use gRPC. UDP belongs to a small set of well-defined niches.
| Workload | Use UDP? | Why |
|---|---|---|
| Live video / audio | Yes | A late frame is worse than a missing one |
| Multiplayer game position updates | Yes | Same: stale state is worse than missing state |
| Service discovery on a LAN | Yes | Broadcast/multicast fits, occasional loss is fine |
| Metrics emission at high rate | Yes | Loss of 0.1% of samples is acceptable |
| DNS query/response | Yes | Small, bounded, retry on timeout, very low latency |
| HTTP API request | No | Needs reliable delivery and ordered bytes |
| File upload | No | Needs every byte, in order, intact |
| Database transaction | No | Needs delivery confirmation |
| Payment authorization | No | Reliability and confirmation are the whole point |
| Long-running RPC over the internet | No | TCP or HTTPS is what you want |
The simplest test: if you'd be unhappy losing one in a thousand messages and you're not willing to build acknowledgement and retry yourself, you don't want UDP. If you'd be fine losing one in a thousand (because the next one is right behind it), UDP starts to make sense.
To pull everything together, here's a small end-to-end example. A backend "stock service" publishes live stock counts for one product to a multicast group every 200 ms. In-store display panels join the group and update their display whenever they receive an update. If a packet is lost, the display just shows the previous count for a fraction of a second longer.
Output (interleaving varies):
One publisher, two displays, one send per update, two deliveries per update. Adding a hundred more displays wouldn't change the publisher's behavior or bandwidth at all. The sequence number in each payload is what would let a display detect a gap if a packet were lost; in this loopback demo, no packets are lost. The ReuseAddress socket option lets multiple UdpClient instances on the same host bind to port 5060 simultaneously, which is how two panels can run inside the same process for this example.
This is the shape UDP is good for: high-frequency, low-stakes, one-to-many. Replace it with TCP and you'd need persistent connections per panel, a fan-out service, and a connection state machine. UDP fits the workload in a few dozen lines.
10 quizzes