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 callingsendto()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()returnsb""). 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
\nand 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 inTIME_WAITfrom the last run. SetSO_REUSEADDRbeforebind(), 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 calledconnect()on the UDP socket first, a later send can surface aConnectionRefusedErrorfrom 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:
- TCP/IP Socket Programming in C# — connection-oriented sockets, message framing, blocking vs asynchronous I/O, and full client/server projects.
- UDP Socket Programming in C# — datagrams, broadcast and multicast, and building a reliability layer on top of UDP.
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