TCP vs UDP Sockets: Same Code, Different Guarantees

By — SDET & creator of findWhatIsMyIP.com · 2026-09-08

A socket is the handle your program holds onto one end of a network conversation. You create it, you point it at an address and port, and you read and write bytes. The surprising part, the first time you write both, is how little the code changes between TCP and UDP — and how much the behaviour does. One constant, SOCK_STREAM versus SOCK_DGRAM, is the difference between "the bytes I sent will arrive, in order, exactly once" and "the bytes I sent might arrive."

Here is the smallest useful version of each: an echo server that sends back whatever it receives, and a client that sends one line and prints the reply. Python, standard library only.

TCP: a connection and a byte stream

Server:

import socket

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 9000))
srv.listen(16)
print("tcp echo on :9000")

while True:
    conn, addr = srv.accept()          # blocks until a client connects
    with conn:
        while True:
            data = conn.recv(4096)     # up to 4096 bytes, or b"" at EOF
            if not data:
                break                  # client closed the connection
            conn.sendall(data)         # sendall loops until every byte is queued

Client:

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("127.0.0.1", 9000))     # 3-way handshake happens here
    s.sendall(b"hello over tcp\n")
    print(s.recv(4096))

listen() marks the socket as accepting connections; accept() hands you a new socket for one client while the original keeps listening. connect() performs the SYN / SYN-ACK / ACK handshake, so by the time it returns you have a real, two-way pipe.

UDP: no connection, just datagrams

Server:

import socket

srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
srv.bind(("0.0.0.0", 9001))
print("udp echo on :9001")

while True:
    data, addr = srv.recvfrom(65535)   # one whole datagram + who sent it
    srv.sendto(data, addr)             # reply to that address

Client:

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(b"hello over udp", ("127.0.0.1", 9001))
data, addr = s.recvfrom(65535)
print(data)

No listen(), no accept(), no connect(). There is no connection to establish. Every sendto() is a self-contained packet with a destination stapled to it; every recvfrom() tells you which address the packet came from so you can reply. One socket serves every client.

The same shape in C# / .NET

The Berkeley sockets model is the same everywhere — only the wrapper changes. In .NET the raw System.Net.Sockets.Socket class is a near one-to-one match for the calls above, and TcpListener / TcpClient / NetworkStream are the friendlier layer on top. The TCP echo server, in C#:

using System.Net;
using System.Net.Sockets;
using System.Text;

var listener = new TcpListener(IPAddress.Any, 9000);
listener.Start();
Console.WriteLine("tcp echo on :9000");

while (true)
{
    using var client = await listener.AcceptTcpClientAsync();   // like accept()
    await using var stream = client.GetStream();
    var buffer = new byte[4096];
    int n;
    while ((n = await stream.ReadAsync(buffer)) > 0)             // 0 = peer closed
        await stream.WriteAsync(buffer.AsMemory(0, n));          // echo it back
}

And the client is the same three moves — connect, write, read:

using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", 9000);
await using var stream = client.GetStream();
await stream.WriteAsync(Encoding.UTF8.GetBytes("hello over tcp\n"));

var buffer = new byte[4096];
int n = await stream.ReadAsync(buffer);
Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, n));

For UDP, UdpClient is the equivalent: ReceiveAsync() returns a UdpReceiveResult carrying both the datagram and the sender's endpoint, and SendAsync(bytes, endpoint) replies to it — the recvfrom / sendto pair, typed. The NetworkStream in the C# TCP code is doing exactly what the byte-stream discussion below is about: it is a stream, so the same framing rules apply, and ReadAsync can hand you a partial or coalesced message just as recv() does in Python.

What TCP does for you that UDP doesn't

  • Delivery. TCP numbers every byte, acknowledges what arrived, and retransmits what didn't. UDP sends the packet once. If a router drops it, it is gone and nobody is told.
  • Order. TCP reassembles segments into the exact sequence you sent. UDP datagrams can arrive out of order, or twice, and it is on you to notice.
  • Flow and congestion control. A TCP sender watches for loss and acknowledgement timing and slows down; send() blocks (or returns "would block") when the pipe is full, giving you natural backpressure. A UDP sender has no idea the link is saturated — keep calling sendto() and the excess packets are simply discarded, often before they leave your machine.
  • A connection identity. TCP gives each client its own socket and a clean end-of-stream signal (recv() returns b""). With UDP there is no "connected" or "disconnected" — only packets that did or didn't turn up.

The one thing UDP makes easier: message boundaries

A TCP socket is a stream of bytes, not a stream of messages. The network is free to split one sendall() into several segments or glue several together, so a recv(4096) can hand you half a message, or two messages stuck together. The echo server above gets away with it because it echoes whatever bytes it happens to read. A real protocol cannot — it has to frame each message itself:

  • a fixed-size header that states the body length, then read exactly that many bytes;
  • a length prefix (e.g. 4 bytes, big-endian) before each message;
  • or a delimiter such as \n and a buffer you split on it.

"I called send once, why did the other side recv twice?" is the classic first TCP bug, and the answer is always framing. UDP sidesteps it: one sendto() is exactly one recvfrom(), up to the ~65 KB datagram limit (and in practice you keep datagrams under ~1400 bytes to avoid IP fragmentation).

The errors you'll actually hit

  • Connection refused (TCP) — connect() reached the host but nothing is listening on that port. Wrong port, or the server isn't up.
  • Address already in use (TCP) — bind() failed because the port is still in TIME_WAIT from the last run. Set SO_REUSEADDR before bind(), as the server above does.
  • Broken pipe / connection reset (TCP) — you wrote to a socket the peer has already closed or dropped. Expect it; handle it as "client gone."
  • Timed out — no response inside the socket's timeout. Common on a slow or lossy link, or a server that's overloaded. Always set sock.settimeout(...); the default is to block forever.
  • UDP: usually nothing. sendto() to a dead port typically just succeeds — the packet leaves and vanishes. If you called connect() on the UDP socket first, a later send can surface a ConnectionRefusedError from the ICMP "port unreachable" the peer sent back, but you can't rely on that.

Which one should you use?

Reach for TCP when the data has to arrive intact and in order and you don't want to reinvent that: web traffic, APIs, SSH, database connections, file transfer, message queues. It is the right default.

Reach for UDP when a late packet is worthless, so dropping it beats waiting for it: live voice and video, online game state, real-time telemetry and metrics, DNS queries (one small request, one small reply), service discovery on a LAN. Also when you intend to build your own transport on top — that is exactly what QUIC and WireGuard do.

A useful gut check: if you would be angry to lose a byte, use TCP. If you would rather have fresh data than complete data, use UDP.

Going further: build the real thing

The echo pairs above are the skeleton. A production client and server add framing, timeouts, async I/O, cancellation, graceful shutdown, and reconnect logic — and in .NET there is a fair bit of surface area between the raw Socket, TcpClient/UdpClient, pipelines, and System.IO.Pipelines. If you want to work through all of that in C# / .NET as a build rather than piece it together from the docs, these two Udemy courses do it step by step:

Disclosure: the links above are affiliate links. If you sign up through them we may earn a commission, at no extra cost to you. See our Affiliate Disclosure.

Frequently asked questions

Why does recv() return only part of my message, or two messages joined together?

Because a TCP socket is a byte stream, not a message channel. TCP guarantees the bytes arrive in order with none missing, but it does not preserve the boundaries of your send() calls — the network can split one send into several segments or coalesce several sends into one. Your application has to add its own framing: a fixed-length header, a length prefix before each message, or a delimiter such as a newline. UDP does preserve boundaries (one sendto is one recvfrom), which is one of the few things it makes easier.

Is UDP always faster than TCP?

No. On a clean, low-loss link the throughput is similar, and TCP is often faster for bulk transfer because its congestion control finds the right send rate. UDP wins on latency for small, time-sensitive messages: there is no handshake, no retransmit wait, and no head-of-line blocking where one lost packet stalls everything behind it. The trade is that UDP gives you nothing back when a packet is dropped — you either tolerate the loss or rebuild reliability yourself.

Do I need SO_REUSEADDR on my server socket?

Usually yes for a TCP server you restart often. After a server closes, the OS keeps the listening port in a TIME_WAIT state for a minute or two, and a fresh bind() to the same port fails with "Address already in use" until that clears. Setting SO_REUSEADDR before bind() lets the new process take the port immediately. It does not let two live sockets share the same TCP port; that is a different option (SO_REUSEPORT).

Where do WebSockets and QUIC fit in?

A WebSocket runs on top of TCP: it starts as an HTTP request, upgrades the connection, and then gives you a message-oriented, bidirectional channel through firewalls and proxies that only allow web traffic. QUIC runs on top of UDP: it rebuilds ordering, reliability and congestion control in user space, adds TLS by default, and avoids TCP head-of-line blocking — it is the transport under HTTP/3. Both are "sockets" underneath; they just move where the reliability logic lives.

Related reading: What is TCP/IP · What is UDP · TCP/IP packet structure · Port scanning in Python · Networking & security glossary

Awesome findWhatIsMyIP Blog