Skip to content

Setting Socket Options on asyncio Streams

asyncio streams hide the socket, but sometimes an option has to be set on it: keepalive for long-lived connections, buffer sizes, linger behaviour on close. asyncio already sets one important option itself, and the socket object it hands out is deliberately restricted. Measured on Python 3.14 over loopback: a new asyncio TCP connection had TCP_NODELAY = 1 and SO_KEEPALIVE = 0. A server that sent each reply as a 15-byte header followed by a 100-byte body answered in 0.05 ms at the median; with TCP_NODELAY turned off, Nagle's algorithm and the client's delayed acknowledgement made the same reply take 41.42 ms, 200 round trips taking 8.26 s instead of 0.01 s. writer.get_extra_info("socket") returned a TransportSocket whose getsockopt and setsockopt worked but whose recv, send and close did not exist. And closing with transport.abort() while 45.8 MB sat in the write buffer gave the server a clean end of stream after 4,157,547 of 50,000,000 bytes — truncated data that looked complete — where SO_LINGER of zero produced a ConnectionResetError instead. This guide shows how to set options and what to expect from them.

Prerequisites

1. Check what asyncio already sets

Before setting anything, read the options on a fresh connection:

reader, writer = await asyncio.open_connection("127.0.0.1", port)
sock = writer.get_extra_info("socket")
print(sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY))    # 1
print(sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE))    # 0

Measured: TCP_NODELAY was 1 — asyncio's selector transport enables it on every TCP connection it creates — and SO_KEEPALIVE was 0. Code copied from blocking-socket examples often sets TCP_NODELAY explicitly; on asyncio streams that is harmless and unnecessary. Keepalive, on the other hand, is off unless you turn it on.

Verify: the options your service depends on are read back with getsockopt in a test, not assumed.

2. See why TCP_NODELAY matters

With Nagle's algorithm enabled, the kernel holds a small segment while an earlier one is unacknowledged; the peer delays its acknowledgement hoping to piggyback it on data. A reply written as two small pieces meets both delays:

async def server(reader, writer):
    while True:
        await reader.readexactly(16)
        writer.write(b"HDR:0000000100\n")     # 15-byte header, sent at once
        await asyncio.sleep(0)
        writer.write(b"b" * 100)              # 100-byte body: held by Nagle until the ACK
        await writer.drain()

Measured over 200 request–reply round trips: with asyncio's default TCP_NODELAY=1, a median of 0.05 ms and a p99 of 0.09 ms. After sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0) on the server's socket, a median of 41.42 ms and a p99 of 42.26 ms — the client's delayed-ACK timer on Linux — and 8.26 seconds for 200 round trips instead of 0.01. If a service's latency sits at a suspiciously round 40 ms, check this option on both ends, including on sockets created outside asyncio and passed in with sock=.

Verify: request–reply latency on an idle connection is in microseconds over loopback, not tens of milliseconds.

Median round trip, header + body reply 2 horizontal bars comparing TCP_NODELAY = 1 (asyncio default) with the others. Median round trip, header + body reply TCP_NODELAY = 1 (asyncio default) 0.05 ms TCP_NODELAY = 0 (Nagle on) 41.42 ms 200 round trips took 0.01 s and 8.26 s. Nagle's algorithm meets the peer's delayed ACK.

3. Set options through the transport socket

writer.get_extra_info("socket") returns an asyncio.trsock.TransportSocket, a wrapper that allows option changes but not I/O or lifetime changes that would confuse the transport:

def enable_keepalive(writer, idle=30, interval=10, count=3):
    sock = writer.get_extra_info("socket")
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, idle)       # Linux
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, interval)
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, count)

Measured: getsockopt and setsockopt worked; recv, send and close raised AttributeError because the wrapper does not have them; setblocking(True) raised ValueError: setblocking(): transport sockets cannot be blocking. Read and write through the stream, and close through writer.close(). For server connections, set options at the top of the connection handler; for clients, right after open_connection returns.

Verify: option-setting code runs on every new connection, and reading the value back returns what was set.

What the TransportSocket allows A grid of 4 rows by 2 columns. What the TransportSocket allows operation result getsockopt / setsockopt allowed recv / send AttributeError: no such attribute close AttributeError: use writer.close() setblocking(True) ValueError: transport sockets cannot be blocking Python 3.14; options yes, I/O and lifetime no.

4. Set buffer sizes before connecting

Some options only take full effect if set before the connection is established — receive buffer size, for one, since the TCP window scale is negotiated during the handshake. Create the socket yourself, set the options, connect it, and hand it to asyncio:

async def open_with_options(host, port, rcvbuf=4 * 1024 * 1024):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
    sock.setblocking(False)
    await asyncio.get_running_loop().sock_connect(sock, (host, port))
    return await asyncio.open_connection(sock=sock)

Measured: requesting 4 MiB reported 8,388,608 bytes from getsockopt — Linux doubles the requested value to account for bookkeeping, and caps the request at net.core.rmem_max, 4,194,304 on this machine. For most services the kernel's autotuning is better than a fixed size; set buffers explicitly only for measured high-bandwidth, high-latency links. A socket passed with sock= keeps the options you set, so set TCP_NODELAY yourself if you need it there.

Verify: options that must precede the handshake are set on a socket created before sock_connect, and read back afterwards.

5. Choose how a connection ends

Closing is the option with the most surprising consequences. Measured by writing 50,000,000 bytes to a slow reader and then ending the connection three ways:

writer.close()                              # flush the buffer, then FIN
writer.transport.abort()                    # drop the buffer, then FIN
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
writer.close()                              # RST: the peer sees an error

With close(), the server received all 50,000,000 bytes and then end of stream. With abort(), 45,842,453 bytes still in the transport's buffer were discarded, and the server read 4,157,547 bytes followed by a clean end of stream — indistinguishable, at the TCP level, from a complete message. With SO_LINGER set to zero, the close sent a reset and the server's read raised ConnectionResetError. When an aborted transfer must not look like a finished one, either use a framing that carries the length, as in implementing a length-prefixed framing protocol, or reset the connection so the peer sees an error.

Verify: a test that aborts a transfer mid-stream makes the receiver report an error or an incomplete message, never a complete one.

Ending a connection with 45.8 MB still buffered A grid of 3 rows by 2 columns. Ending a connection with 45.8 MB still buffered client action what the server saw writer.close() 50,000,000 bytes, then EOF transport.abort() 4,157,547 bytes, then a clean EOF SO_LINGER(1, 0) + close() ConnectionResetError abort() truncates silently; only a reset or framing reveals it.

Verification

Socket options are set correctly when:

  • Defaults are known: TCP_NODELAY on, SO_KEEPALIVE off for asyncio TCP connections.
  • Options are set through the transport socket and read back in tests.
  • Handshake-time options are set on a socket created before connecting.
  • Abortive closes are either reported to the peer with a reset or detectable through framing.

Diagnostic Hook: when small request–reply exchanges take about 40 ms each, read TCP_NODELAY on both sockets. Turning it off on one side of a header-then-body reply raised the median from 0.05 ms to 41.42 ms.

Pitfalls & edge cases

  • Disabling TCP_NODELAY, or forgetting it on sock= sockets. Measured: 41 ms per round trip.
  • Calling recv or close on the transport socket. They do not exist on the wrapper.
  • Setting buffer sizes after connecting. Window scaling is fixed at the handshake.
  • abort() as a cancel signal. The peer saw a clean end of stream after 4.2 of 50 MB.

Frequently Asked Questions

Does asyncio set TCP_NODELAY by default?

Yes. A new asyncio TCP connection read back TCP_NODELAY = 1. SO_KEEPALIVE was 0 and must be enabled per connection if needed.

How do I set a socket option on an asyncio StreamWriter?

Call writer.get_extra_info("socket") and use setsockopt on the TransportSocket it returns. It allows option changes but not recv, send, close or blocking mode.

Why do my asyncio replies take 40 ms?

Nagle's algorithm is on for one socket and the peer delays its ACK. With TCP_NODELAY off, a header-then-body reply took 41.42 ms instead of 0.05 ms.

What is the difference between close() and abort() on an asyncio transport?

close() flushes buffered data first; abort() discards it. With 45.8 MB buffered, abort() delivered 4.2 of 50 MB followed by a normal end of stream.