Skip to content

Half-Closing TCP Connections in asyncio

TCP lets each side stop sending independently: a client can say "that is the whole request" and still read the response. Protocols that delimit a request by end of stream — piping data through nc, some RPC and proxy designs, upload-then-respond tools — depend on that half-close. asyncio supports it, with three sharp edges. Measured on Python 3.14, sending 100,000 bytes to a server that replies with their SHA-256 hash after reading to end of stream: a client that called writer.write_eof() received the 64-byte reply; a client that called writer.close() instead received 0 bytes; a client that sent no end of stream waited until its 3-second timeout. Over TLS, can_write_eof() returned False and write_eof() raised NotImplementedError. And on the server side, a Protocol whose eof_received returned None — the default — lost a reply sent 0.1 s later (0 bytes), while returning True kept the transport open and the reply arrived. This guide covers each case.

Prerequisites

1. Write a server that reads to end of stream

With streams, reader.read() with no argument reads until the peer's end of stream. The server can still write afterwards, because only the client's direction is closed:

async def hash_server(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
    data = await reader.read()                         # until the client half-closes
    writer.write(hashlib.sha256(data).hexdigest().encode())
    await writer.drain()
    writer.close()
    await writer.wait_closed()

server = await asyncio.start_server(hash_server, "0.0.0.0", 9000)

The protocol here is "everything until EOF is the request". It needs no length prefix and no delimiter, and it works with any sender that can half-close — including shell tools.

Verify: a client that sends data and half-closes receives the reply; one that never half-closes is cut off by a server-side read timeout rather than holding the connection open forever.

2. Half-close from the client with write_eof

The client sends its data, then calls write_eof() to close its sending direction, then reads:

async def hash_remote(data: bytes) -> str:
    reader, writer = await asyncio.open_connection("hash-svc", 9000)
    writer.write(data)
    await writer.drain()
    writer.write_eof()                                # "request complete"; reading still works
    async with asyncio.timeout(10):
        reply = await reader.read()
    writer.close()
    await writer.wait_closed()
    return reply.decode()

Measured with 100,000 bytes: the 64-byte hash arrived. With writer.close() in place of write_eof(), the client's read() returned 0 bytes — closing the writer closes the whole connection, so the reply had nowhere to arrive. With neither, the server waited for an end of stream that never came, and the client's read hit its 3-second timeout. write_eof() flushes buffered data before sending the FIN, so it is safe to call right after write().

Verify: client code that signals end of request uses write_eof(), never close(), before reading the reply.

Sending 100,000 bytes, expecting a 64-byte reply A grid of 6 rows by 2 columns. Sending 100,000 bytes, expecting a 64-byte reply case result client write_eof(), then read 64-byte reply client close(), then read 0 bytes client sends no EOF TimeoutError after 3 s TLS: can_write_eof() / write_eof() False / NotImplementedError Protocol server, reply 0.1 s after EOF, eof_received returns None 0 bytes same, eof_received returns True 64-byte reply Python 3.14 asyncio, default event loop.

3. Keep the transport open in Protocol servers

With the lower-level Protocol API, the transport calls eof_received() when the peer half-closes. Its return value decides what happens next: a false value — including the implicit None — closes the transport; True keeps it open for writing:

class HashProtocol(asyncio.Protocol):
    def connection_made(self, transport):
        self.transport, self.buf = transport, bytearray()

    def data_received(self, data):
        self.buf += data

    def eof_received(self):
        asyncio.get_running_loop().create_task(self.reply())   # reply after async work
        return True                                              # keep the write side open

    async def reply(self):
        digest = await compute_digest(self.buf)
        self.transport.write(digest)
        self.transport.close()

Measured with a reply scheduled 0.1 s after end of stream: returning None closed the transport before the reply, and the client received 0 bytes; returning True delivered all 64 bytes. A reply written synchronously inside eof_received arrived either way, because the transport flushes buffered data when it closes — which hides the bug until the reply involves an await. Streams-based servers do not have this problem on plain TCP: StreamReaderProtocol.eof_received returns True for you (and False over TLS, where half-close is unavailable).

Verify: every eof_received that precedes a reply returns True and the code closes the transport explicitly after replying.

4. Use explicit framing over TLS

TLS cannot half-close: its protocol has a closure alert for the whole connection, not one direction. Measured: on a TLS stream, writer.can_write_eof() returned False, and write_eof() raised NotImplementedError. A protocol that relies on half-close cannot move to TLS unchanged. Frame the request instead:

async def send_framed(writer, payload: bytes):
    writer.write(len(payload).to_bytes(8, "big") + payload)     # length prefix, no EOF needed
    await writer.drain()

async def read_framed(reader, limit=64 * 1024 * 1024) -> bytes:
    size = int.from_bytes(await reader.readexactly(8), "big")
    if size > limit:
        raise ValueError(f"frame of {size} bytes exceeds limit")
    return await reader.readexactly(size)

Length-prefixed framing, built out in implementing a length-prefixed framing protocol, works over plain TCP and TLS alike, lets one connection carry many requests, and lets the server reject oversized requests before reading them. Check can_write_eof() in code that may run over either transport, and choose framing at protocol design time rather than relying on end of stream.

Verify: code that may run over TLS either checks can_write_eof() or uses framing that does not need half-close.

Request, half-close, reply A sequence of 6 messages between 2 participants. Request, half-close, reply client server 100,000 bytes write_eof(): FIN, client stops sending reader.read() returns at EOF 64-byte hash on the open direction close reader.read() returns the reply Each direction of a TCP connection closes on its own.

5. Bound the wait for end of stream

A server that reads to end of stream waits as long as the client keeps its side open, so a slow or broken client holds a connection and its buffer indefinitely. Bound both the time and the size:

async def hash_server(reader, writer, limit=64 * 1024 * 1024):
    try:
        async with asyncio.timeout(30):
            data = bytearray()
            while chunk := await reader.read(65536):
                data += chunk
                if len(data) > limit:
                    raise ValueError("request too large")
        writer.write(hashlib.sha256(data).hexdigest().encode())
        await writer.drain()
    except (TimeoutError, ValueError) as e:
        log.warning("rejected %s: %s", writer.get_extra_info("peername"), e)
    finally:
        writer.close()
        await writer.wait_closed()

Reading in chunks rather than with a single read() lets the server enforce the size limit as data arrives. The client measured without an end of stream in step 2 would, against this server, be disconnected after 30 seconds instead of holding the connection. For the same limits on HTTP bodies, see limiting request body size in ASGI apps.

Verify: a client that never half-closes is disconnected after the timeout, and one that sends more than the limit is rejected without exhausting memory.

Choosing how a request ends A flow of 4 stages. Choosing how a request ends Plain TCP, EOF-delimited client write_eof() Server read to EOF, timeout + size limit Protocol API eof_received returns True TLS or many requests length-prefixed frames Half-close is a plain-TCP feature; framing works everywhere.

Verification

Half-closing works correctly when:

  • Clients call write_eof(), not close(), to end a request whose reply they still need.
  • Protocol.eof_received returns True whenever a reply follows end of stream.
  • TLS paths use framing, or check can_write_eof() first.
  • Servers bound how long and how much they read before end of stream.

Diagnostic Hook: when a client intermittently gets an empty reply from a custom TCP service, check how it ends its request. A client calling close() received 0 bytes here, and a Protocol server returning None from eof_received lost a reply sent after an await.

Pitfalls & edge cases

  • close() instead of write_eof(). Measured: 0-byte reply.
  • Half-close over TLS. write_eof() raised NotImplementedError.
  • Default eof_received. A delayed reply was lost when it returned None.
  • Reading to EOF without limits. A client that never half-closes holds the connection.

Frequently Asked Questions

How do I half-close a TCP connection in asyncio?

Call writer.write_eof() after writing the request. It sends a FIN for your direction only, so you can still read the reply; close() received 0 bytes instead.

Why does write_eof raise NotImplementedError?

The transport cannot half-close; TLS is the common case, where can_write_eof() returned False. Use a length prefix or delimiter to mark the end of a request.

Why is my asyncio Protocol closing after eof_received?

Returning None or False from eof_received closes the transport. Return True to keep writing; a reply sent 0.1 s later was lost with None and delivered with True.

How does an asyncio server know a request is complete?

By end of stream, with reader.read() returning at the client's write_eof(), or by framing such as a length prefix, which also works over TLS.