asynchronous programming
socket server
end-of-stream detection
network programming
server development

How to determine stream end in a asynchronous socket server

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

In an asynchronous socket server, one of the easiest mistakes is to confuse the end of a stream with the end of a message. A TCP connection is just a sequence of bytes, so your server needs one rule for transport shutdown and another rule for application framing. If those ideas get mixed together, you end up with truncated payloads, hung connections, or parsers that behave differently under load.

The practical rule is simple: treat end-of-stream as a transport event. Then let your protocol parser decide whether a complete message was received before that event happened.

EOF Means the Peer Stopped Sending Bytes

For stream-oriented sockets, end-of-file usually appears when a read operation returns zero bytes. In Python asyncio, that means reader.read(...) gives you b"".

python
1import asyncio
2
3async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
4    peer = writer.get_extra_info("peername")
5    print(f"connected: {peer}")
6
7    try:
8        while True:
9            chunk = await reader.read(4096)
10            if chunk == b"":
11                print(f"eof from {peer}")
12                break
13
14            print(f"received {len(chunk)} bytes")
15    finally:
16        writer.close()
17        await writer.wait_closed()
18        print(f"closed: {peer}")

That empty-bytes result is the transport-level signal that no more data will arrive from the peer. It does not mean you received a valid business message. It only means the byte stream ended.

Python also exposes reader.at_eof(), which can be useful for state checks, but a direct zero-length read is still the clearest signal inside a read loop.

Do Not Use Connection Close as the Message Boundary

Many beginners implicitly design a protocol where “one connection equals one message.” That works for a few narrow cases, but most real servers need something better. A long-lived connection can carry many messages, partial messages, or pipelined messages.

That is why the protocol needs framing. Common choices are:

  • newline-delimited records
  • fixed-size frames
  • length-prefixed payloads
  • a higher-level format such as HTTP or WebSocket that defines framing for you

Here is a small length-prefixed reader:

python
1import asyncio
2import struct
3
4async def read_frame(reader: asyncio.StreamReader) -> bytes:
5    header = await reader.readexactly(4)
6    length = struct.unpack("!I", header)[0]
7    payload = await reader.readexactly(length)
8    return payload

In this design, a message ends when the parser has read the full length specified in the header. EOF is still important, but it is now treated as a connection event, not as the normal message terminator.

Handle Partial Reads and Abrupt Disconnects

Because TCP is a byte stream, one read call can return half a message, one whole message, or multiple messages together. Your server has to accumulate bytes until the framing rule says a complete message is available.

That is why readexactly is often useful for framed protocols. If the client disconnects early, Python raises asyncio.IncompleteReadError, which tells you the message was cut off.

python
1import asyncio
2import struct
3
4async def read_frame_safe(reader: asyncio.StreamReader) -> bytes | None:
5    try:
6        header = await reader.readexactly(4)
7        size = struct.unpack("!I", header)[0]
8        return await reader.readexactly(size)
9    except asyncio.IncompleteReadError:
10        return None

Returning None here gives the caller a clear signal that the stream ended before a full frame arrived. That is very different from successfully reading an empty application message.

Timeouts Matter Too

Not every broken connection ends with a clean EOF. Some clients simply stop sending. In an asynchronous server, that can leave tasks waiting forever unless you define a timeout policy.

python
1import asyncio
2
3async def read_with_timeout(reader: asyncio.StreamReader) -> bytes:
4    return await asyncio.wait_for(reader.read(1024), timeout=30)

Use timeouts for idle protection, then close the writer and log the reason. Operationally, it helps to distinguish these cases:

  • normal EOF from peer shutdown
  • idle timeout
  • protocol parse error
  • abrupt disconnect during a frame
  • server-initiated close

Those events all mean “the connection ended,” but they should not be treated as the same failure mode.

Keep Transport Handling Separate From Parsing

A reliable async server usually has one layer that reads bytes and manages connection lifetime, and another that interprets bytes according to protocol rules. Keeping those responsibilities separate makes the code easier to test.

For example, the connection layer can decide when to stop reading and close resources. The parser can focus on questions like “Do I have a full frame?” or “Is the JSON payload complete?” That separation also makes it easier to switch from one framing rule to another without rewriting socket-lifecycle code.

Common Pitfalls

The most common mistake is treating b"" as a valid empty message instead of as EOF. In stream APIs, that empty result usually means the other side is done sending.

Another frequent bug is assuming one read call equals one message. TCP does not preserve message boundaries, so that assumption breaks as soon as packets arrive differently.

A third problem is relying on connection closure to delimit application messages. That makes long-lived connections awkward and turns partial failures into protocol ambiguity.

Summary

  • In an async stream server, EOF usually appears as a zero-length read.
  • EOF means the transport ended, not that a complete message necessarily arrived.
  • Define message framing explicitly with delimiters, fixed sizes, or length prefixes.
  • Handle partial reads and IncompleteReadError so truncated messages are visible.
  • Keep connection lifecycle logic separate from protocol parsing and timeout policy.

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.