A TCP socket is the layer underneath HTTP, gRPC, WebSockets, and most other client protocols. HTTP is built on TCP; this lesson is the layer below. C# exposes TCP through System.Net.Sockets, with TcpListener and TcpClient as wrappers on top of the raw Socket class. This lesson covers when dropping below HTTP makes sense, how to write an async TCP server and client, the framing problem, and how to shut connections down without losing data or leaking handles.
For most network work in C#, HttpClient is the answer. It handles request framing, headers, status codes, content negotiation, connection pooling, retries, and a long list of details. Use raw TCP when HTTP isn't a good fit for the traffic shape, not because TCP is "lower-level and therefore better".
TCP sockets are useful in narrow but real cases:
For everything else, including REST APIs, file downloads, and most service-to-service calls, use HTTP. The rest of this lesson assumes raw TCP is actually needed.
Raw TCP means owning framing, encoding, versioning, error semantics, and reconnection logic. HTTP provides all of these. Pick TCP only when its benefits outweigh that engineering tax.
A TCP server's job is to accept incoming connections on a known port and do something with each one. In .NET, the wrapper for that is System.Net.Sockets.TcpListener. It owns the listening socket, exposes an async AcceptTcpClientAsync method that returns a TcpClient per incoming connection, and stays out of the way once the connection is established.
An echo server is the smallest useful TCP program. The server reads whatever the client sends and writes it straight back. It exercises every part of the stack: accepting connections, reading from a stream, writing to a stream, and closing things down. A complete async echo server using top-level statements:
Output (when a client sends "hello"):
The flow is the same as in any TCP server, in any language. TcpListener is created against an IP and a port. Start opens the listening socket and begins queuing incoming connection attempts. AcceptTcpClientAsync returns a Task<TcpClient> that completes when a client connects. GetStream exposes a NetworkStream for read/write. Closing the TcpClient tears down the connection and the operating system releases the socket.
IPAddress.Loopback binds the listener to 127.0.0.1, which only accepts connections from the same machine. Use IPAddress.Any to listen on all network interfaces (useful when remote clients need to connect). Port 5000 is an example. Ports below 1024 typically require elevated privileges on Linux and macOS.
The accept loop here handles one client at a time. The next client has to wait for the current one to finish before it gets serviced. That's fine for a hello-world example and wrong for a real server. The "Multi-client Servers" section below fixes it.
AcceptTcpClientAsync does not block any thread while waiting. The OS holds the pending connections in a kernel queue (the accept backlog) and the runtime resumes the method when a connection arrives. The default backlog size is small (typically 50). Pass an int to Start to handle bursts.
The matching client opens a connection to a server, exchanges some bytes, and disconnects. TcpClient is the wrapper. Construct one, call ConnectAsync with the server's host and port, grab the NetworkStream, and use it like any other Stream.
The client that pairs with the echo server above:
The using var declaration matters. TcpClient implements IDisposable, and disposing it closes the underlying socket. Without the using, an exception part-way through the conversation would leak the connection until the garbage collector ran the finalizer.
ConnectAsync performs the TCP three-way handshake. The diagram below shows the standard SYN, SYN-ACK, ACK exchange that establishes a connection. None of this is application code; it's what ConnectAsync does on the caller's behalf.
After the handshake, the connection is a full-duplex byte stream: either side can write at any time, and reads see bytes in the order they were sent. The close at the end is a separate handshake using FIN packets, which is why graceful shutdown takes more than disposing the client.
TcpClient also exposes a synchronous Connect that blocks the calling thread until the handshake finishes or fails. Use ConnectAsync in any code that's already async, which is most modern code.
Once a connection exists, NetworkStream is how to read and write bytes. It inherits from System.IO.Stream, the same base class as FileStream and MemoryStream, so the read/write API is familiar.
WriteAsync sends bytes. The contract is "the runtime guarantees these bytes are queued for transmission". It doesn't wait for the peer to receive them, and it doesn't wait for them to leave the local network buffer. As soon as the kernel accepts the bytes into its outgoing buffer, WriteAsync returns.
ReadAsync reads bytes. The contract here is more subtle and is the source of a common TCP bug in .NET code: `ReadAsync` returns when at least one byte is available, not when the buffer is full. A request for 1024 bytes with only 200 arrived returns 200. The remaining 824 are still in flight on the network.
A bytesRead of 0 is the value that means "the peer has gracefully closed its end". After that point, no more bytes will arrive. Calling ReadAsync on a closed stream keeps returning 0. Treating 0 as "no data yet, try again" turns into a 100% CPU loop.
The Memory<byte> overloads of ReadAsync and WriteAsync are the modern preferred API. They avoid an array allocation in some scenarios and integrate with span-based code:
ArrayPool<byte>.Shared reuses buffers across calls, which matters for servers that handle many short reads. For an echo server doing a handful of reads, a stack byte[] is fine. For a high-throughput server, pooling cuts allocation and GC pressure significantly.
Every ReadAsync and WriteAsync allocates a small completion object internally if the operation doesn't finish synchronously. For sustained high throughput, the ValueTask-returning overloads (introduced in .NET Core 2.1) avoid most of those allocations.
TCP is a byte stream, not a message system. There are no "messages". There are only bytes flowing in order. If the client calls WriteAsync three times with "hello", " ", and "world", the server might see:
"hello world"."hello", " ", "world"."hel" and "lo world".The OS, the network card, the routers in between, and the receiver's TCP stack are all allowed to combine, split, and delay bytes for any reason. The server has no way to know "this batch is one message". The boundaries must be encoded into the bytes themselves.
The two common framing strategies are length-prefixed and delimiter-based. HTTP uses both: headers are line-delimited, the body is length-prefixed via Content-Length. WebSockets use length-prefixed binary frames. Most custom protocols pick length-prefix because it handles binary data and arbitrary payload sizes without escaping.
Length-prefix framing works like this. Before each payload, the sender writes a fixed-size header (commonly 4 bytes, interpreted as an int) containing the payload's length. The receiver reads 4 bytes, decodes the length, then reads exactly that many bytes. Repeat for each message.
The diagram shows the contract: the sender promises that every message starts with a 4-byte length, and the receiver always reads exactly that many bytes for the payload. As long as both sides agree on the framing rule, the byte stream becomes a message stream.
The receiver loop is the tricky part. Because ReadAsync can return partial reads, a single call cannot be assumed to deliver the whole header. A helper that loops until the requested number of bytes has arrived is needed:
The loop keeps reading until either the requested count is satisfied or the peer closes the connection mid-read. The end-of-stream check is critical: if the connection drops half-way through a header, partial data must not be treated as a valid message.
.NET 7 added Stream.ReadExactlyAsync that does this, so on modern .NET:
For older targets, the manual helper applies. Either way, the message-reading code on top is the same:
A few details. The length is encoded as big-endian (network byte order). That's the convention for almost every binary protocol on the wire, and BinaryPrimitives exposes the helpers. The length > 1_000_000 bound is a sanity check: a malicious or buggy peer could send a length of int.MaxValue, which would cause a 2 GB allocation. Always validate framing fields before trusting them.
The InvalidDataException is from System.IO. It's the appropriate type to throw when the data on the wire doesn't match the parsed protocol. The Exception Handling section covers the broader exception strategy; here, throwing a specific type lets the caller decide whether to disconnect or recover.
The accept loop in the first example handles one client at a time. The fix is to start a new task for each accepted connection and immediately go back to accepting more. Each connection runs its own read/write loop on its own task, isolated from the others.
The "order broadcast" server below accepts connections from store-frontend processes and streams order events. Each connection is independent: a slow client doesn't block the others.
The shape is the standard async accept loop: accept a connection, fire off a task to handle it, loop. The _ = discards the returned Task because the accept loop doesn't await per-connection work; if it did, the server would still be one-at-a-time. The per-connection task lives until the client disconnects (ReadFramedMessageAsync throws EndOfStreamException on a clean close) or an error knocks it down.
Two trade-offs come up here. First, fire-and-forget tasks make exceptions easy to lose. The try/catch inside HandleClientAsync catches everything that could come up, logs it, and lets the task end cleanly. Without a catch inside a fire-and-forget task, TaskScheduler.UnobservedTaskException must be configured to even see them.
Second, the connection lifetime is bounded by the listener's cancellation token. When the server's cts is cancelled, ongoing ReadAsync calls throw OperationCanceledException, the per-connection task unwinds, and the connection's using blocks dispose the stream and client. That's how graceful shutdown works in practice, covered in the next section.
The accept-loop architecture for a real multi-client server:
The listener accepts. The accept loop spawns one task per connection. A shared cancellation token can stop all of them in unison. Each per-connection task is independent in terms of work but shares the lifecycle signal.
Each per-connection task allocates a small state machine plus its own stack frames during async operations. Thread pool threads are not pinned to connections; an idle connection consumes almost no CPU. Tens of thousands of concurrent connections per server are realistic with this design.
Closing a TCP connection well is harder than opening one. The hard requirement is that both sides agree the conversation is over, no in-flight data is lost, and no socket handles are leaked. The pieces that make this work are cancellation tokens, Socket.Shutdown, and proper disposal of streams and clients.
A CancellationToken is the signal. Pass it to every async read and write, plus the accept loop. To stop, cancel the token. Pending ReadAsync and WriteAsync calls throw OperationCanceledException, the per-connection tasks see it, unwind through their using blocks, and dispose the streams and clients.
Socket.Shutdown(SocketShutdown.Both) is the half-close mechanism. It tells the kernel "I'm done sending on this socket" without immediately killing the receive direction. The peer sees a clean EOF on its next read (returning 0 bytes), processes any pending data, and closes its own end. This is the difference between a graceful close (FIN handshake) and an abrupt one (RST, which throws SocketException on the peer).
TcpClient.Close and disposing the client end up calling Socket.Close, which sends a FIN if the connection is still alive. For most cases, using var client = new TcpClient() is enough. For long-lived servers needing explicit drain semantics, call Shutdown first:
The drain step matters when the peer might have sent data that hasn't been read yet. Closing without draining can send an RST that strands those bytes. Most echo and ACK protocols don't need an explicit drain because every send has a matching receive; long-lived streaming protocols sometimes do.
The complete shutdown for the multi-client server uses a single CancellationTokenSource shared across the accept loop and all connection tasks:
Console.CancelKeyPress fires on Ctrl+C. Setting e.Cancel = true keeps the process alive long enough for the cancellation to propagate. The accept loop's AcceptTcpClientAsync(cts.Token) throws when cancelled, the catch lets the method continue to the finally, and listener.Stop() releases the listening socket.
The pending connection tasks see cts.Token cancelled too, so their reads and writes throw OperationCanceledException, their using blocks dispose the streams and clients, and the FIN packets go out cleanly. To wait for them before exiting, track them in a list and await Task.WhenAll(...) before returning.
Disposing a NetworkStream does not close the underlying socket by default if it was constructed via TcpClient.GetStream; the TcpClient owns the socket. Disposing the TcpClient is what closes the socket. Mixing using on both is harmless but only the outer one matters.
TCP errors come in three flavors in .NET: SocketException for low-level socket failures, IOException wrapping a SocketException for stream-level failures, and OperationCanceledException for cancellation. Knowing which one to catch and what triggered it saves debugging time.
SocketException is thrown by the Socket and TcpClient APIs when the OS reports a failure. Its SocketError property gives the specific error code. The common ones:
SocketError | Cause | What to do |
|---|---|---|
ConnectionRefused | No process listening on that port. | Verify the server is running and the port is right. |
ConnectionReset | Peer aborted the connection (RST), often by crashing or calling Close without draining. | Reconnect, log, treat the in-flight message as failed. |
TimedOut | The OS-level send or receive timed out. | Retry with backoff, surface to the caller. |
HostNotFound | DNS lookup failed for the hostname in ConnectAsync. | Check DNS, verify the hostname. |
NetworkUnreachable | No route from this machine to the destination. | Likely a network or firewall issue. |
AddressInUse | The port is already bound by another process. | Pick a different port or stop the conflicting process. |
IOException shows up in most stream-level code. NetworkStream wraps SocketException inside IOException for consistency with other Stream consumers (e.g., FileStream). The inner exception holds the original SocketException:
Output (when nothing is on port 9999):
ConnectAsync throws SocketException directly because the connection never made it to the stream layer. A read or write on an already-connected socket that then drops surfaces as IOException wrapping a SocketException. The exception-filter syntax (when (ex.SocketErrorCode == ...)) is clean for picking out specific failure modes; the Exception Handling section covers exception filters in depth.
A confusing error in practice is ConnectionReset. It means the peer sent an RST instead of a clean FIN. The common causes:
A reset is not always a bug on the local side. Resilient TCP clients treat ConnectionReset as "the connection is gone, reconnect if appropriate" rather than "something is broken locally".
OperationCanceledException shows up when an await on a ReadAsync or WriteAsync is cancelled via a CancellationToken. It's not an error in the protocol; it's an intentional signal. Catching it separately from IOException allows different logging:
The when (ct.IsCancellationRequested) filter is defensive: it ensures the catch only matches cancellations of the token that was passed in, not unrelated cancellations bubbling up from somewhere else.
Catching exceptions has a small but real cost. For tight inner loops where errors are rare, try/catch is fine. For loops where errors are expected on every iteration (parsing untrusted input, for example), use try-with-Try* patterns or validate up front instead of relying on exceptions for control flow.
TcpListener and TcpClient are wrappers around System.Net.Sockets.Socket. The Socket class supports the full set of socket options: protocol families (IPv4, IPv6, Unix), socket types (Stream, Dgram, Raw), socket options (NoDelay, KeepAlive, SendBufferSize), and direct control of bind, listen, accept, send, receive.
For most TCP work, the raw Socket is unnecessary. The wrappers handle the lifecycle and expose a NetworkStream that integrates with the rest of the BCL's stream-based APIs.
Reasons to drop to raw Socket:
LingerOption or IPV6_V6ONLY.SendAsync(IList<ArraySegment<byte>>, ...) is needed.TcpListener plenty fast for this, but at extreme scale every allocation counts.)For the common case, stay on TcpListener and TcpClient. They're not slower in any way that matters for normal workloads, and the code is shorter and clearer. UDP uses UdpClient, which wraps a different socket type (SocketType.Dgram).
The table below summarizes the three layers .NET exposes:
| Type | Level | Use For |
|---|---|---|
HttpClient | Application | HTTP/HTTPS requests, REST APIs, file downloads |
TcpClient / TcpListener | Transport | Custom binary or text protocols over TCP, long-lived sessions |
Socket | OS-level | Non-TCP protocols, fine-grained socket option control, extreme performance |
10 quizzes