Building a TCP chat server with asyncio¶
This guide walks you through building a TCP chat server where multiple users can connect and exchange messages in real time. Along the way, you will learn how to use asyncio streams for network programming.
The guide assumes basic Python knowledge — functions, classes, and context managers — and a general understanding of async/await.
See also
- A conceptual overview of asyncio
An introduction to the fundamentals of asyncio.
asyncioreference documentationThe complete API reference.
Starting with an echo server¶
Before building the chat server, let’s start with something simpler: an echo server that sends back whatever a client sends.
Accepting connections¶
The core of any asyncio network server is asyncio.start_server(). You
give it a callback function, a host, and a port. When a client connects,
asyncio calls your callback with a StreamReader for receiving
data and a StreamWriter for sending it back.
Here is a minimal callback that accepts a connection, prints the client’s
address, and immediately closes it. The close()
method initiates the connection shutdown, and awaiting
wait_closed() waits until it is fully closed:
async def handle_client(reader, writer):
addr = writer.get_extra_info('peername')
print(f'New connection from {addr}')
writer.close()
await writer.wait_closed()
Running the server¶
To keep the server running and accepting connections, use
serve_forever() inside an async with block.
Using the server as an async context manager ensures it is properly cleaned up
when done, and serve_forever() keeps it running until the
program is interrupted. Finally, asyncio.run() starts the event loop and
runs the top-level coroutine:
async def main():
server = await asyncio.start_server(
handle_client, '127.0.0.1', 8888)
async with server:
await server.serve_forever()
asyncio.run(main())
Reading and writing data¶
To turn this into an echo server, the callback needs to read data from the
client and send it back. readline() reads one line
at a time, returning an empty bytes object when the client
disconnects.
write() buffers outgoing data without sending it
immediately. Awaiting drain() flushes the buffer
and applies back-pressure if the client is slow to read.
With these pieces, the echo callback becomes:
async def handle_client(reader, writer):
addr = writer.get_extra_info('peername')
print(f'New connection from {addr}')
while True:
data = await reader.readline()
if not data:
break
writer.write(data)
await writer.drain()
print(f'Connection from {addr} closed')
writer.close()
await writer.wait_closed()
To test, run the server in one terminal and connect from another using nc
or telnet:
$ nc 127.0.0.1 8888
Building the chat server¶
The echo server handles each client independently — it reads from one client and writes back to the same client. A chat server, on the other hand, needs to deliver each message to every connected client. This means the server must keep track of who is connected so it can send messages to all of them.
Tracking connected clients¶
We store each client’s name and StreamWriter in a
module-level dictionary. When a client connects, handle_client prompts for
a name and adds the writer to the dictionary. A finally block ensures
the client is always removed on disconnect, even if the connection drops
unexpectedly:
connected_clients: dict[str, asyncio.StreamWriter] = {}
async def handle_client(reader, writer):
writer.write(b'Enter your name: ')
await writer.drain()
name = (await reader.readline()).decode().strip()
connected_clients[name] = writer
try:
... # message loop (shown below)
finally:
del connected_clients[name]
writer.close()
await writer.wait_closed()
Broadcasting messages¶
To send a message to all clients, we define a broadcast function.
asyncio.TaskGroup sends to all recipients concurrently rather than
one at a time:
async def broadcast(message, *, sender=None):
"""Send a message to all connected clients except the sender."""
async def send(writer):
# Ignore clients that have already disconnected.
with contextlib.suppress(ConnectionError):
writer.write(message.encode())
await writer.drain()
async with asyncio.TaskGroup() as tg:
for name, writer in list(connected_clients.items()):
if name != sender:
tg.create_task(send(writer))
Adding an idle timeout¶
To disconnect clients who have been idle for too long, wrap the read call in
asyncio.timeout(). This async context manager takes a delay in seconds.
If the enclosed await does not complete within that time, the operation is
cancelled and TimeoutError is raised, freeing server resources when
clients connect but stop sending data:
async with asyncio.timeout(300): # 5-minute timeout
data = await reader.readline()
The complete chat server¶
Putting it all together, here is the complete chat server with client tracking, broadcasting, and an idle timeout:
import asyncio
import contextlib
connected_clients: dict[str, asyncio.StreamWriter] = {}
async def broadcast(message, *, sender=None):
"""Send a message to all connected clients except the sender."""
async def send(writer):
# Ignore clients that have already disconnected.
with contextlib.suppress(ConnectionError):
writer.write(message.encode())
await writer.drain()
async with asyncio.TaskGroup() as tg:
for name, writer in list(connected_clients.items()):
if name != sender:
tg.create_task(send(writer))
async def handle_client(reader, writer):
addr = writer.get_extra_info('peername')
writer.write(b'Enter your name: ')
await writer.drain()
data = await reader.readline()
if not data:
writer.close()
await writer.wait_closed()
return
name = data.decode().strip()
connected_clients[name] = writer
print(f'{name} ({addr}) has joined')
try:
await broadcast(f'*** {name} has joined the chat ***\n', sender=name)
while True:
try:
async with asyncio.timeout(300): # 5-minute timeout
data = await reader.readline()
except TimeoutError:
writer.write(b'Disconnected: idle timeout.\n')
await writer.drain()
break
if not data:
break
message = data.decode().strip()
if message:
print(f'{name}: {message}')
await broadcast(f'{name}: {message}\n', sender=name)
except ConnectionError:
pass
finally:
del connected_clients[name]
print(f'{name} ({addr}) has left')
await broadcast(f'*** {name} has left the chat ***\n')
writer.close()
await writer.wait_closed()
async def main():
server = await asyncio.start_server(
handle_client, '127.0.0.1', 8888)
addr = server.sockets[0].getsockname()
print(f'Chat server running on {addr}')
async with server:
await server.serve_forever()
asyncio.run(main())
Note
This server does not handle two clients choosing the same name. Adding support for unique names is left as an exercise for the reader.
To test, start the server and connect from two or more terminals using nc
or telnet:
$ nc 127.0.0.1 8888
Enter your name: Alice
*** Bob has joined the chat ***
Bob: Hi Alice!
Hello Bob!
Each message you type is broadcasted to all other connected users.