Build a Multi-Client TCP Chat Server
Build a terminal chat app from raw TCP sockets with threading in Python.
Introduction
30 Second Summary
Every time you send a message in a group chat, something invisible happens. Your text travels across a network, reaches a server, and gets forwarded to everyone else in the conversation. But how does that actually work under the hood?
In this project, you will build a multi-client chat server and client from scratch using Python's raw socket primitives. No frameworks, no libraries. Just you, the network, and the code that makes messages flow between terminals.
What You'll Build
A terminal-based chat application where multiple clients connect to a central server over TCP, pick nicknames, and broadcast messages to each other in real time.
By the end of this project, you'll have:
- A threaded TCP chat server that accepts multiple clients and broadcasts messages between them.
- A TCP chat client with concurrent send and receive using threads.
- Hands-on understanding of the socket lifecycle (bind, listen, accept, connect, send, recv) and graceful disconnect handling.
- Secret Mission: Rebuild the messaging layer over UDP to experience the trade-offs firsthand.
Are there any prerequisites?
You should be comfortable with Python basics like functions, loops, and data structures. No networking experience is required.
Set Up Your Project
Every chat application starts with a server waiting for connections and clients reaching out to it. Before you write any networking code, you need a clean workspace with the right tools accessible.
In this step, you will verify that Python is ready in your terminal and create the two files that will become your chat server and client.
In this step, get ready to:
- Verify Python 3.14 is accessible from your terminal.
- Create the tcp-chat project folder with server.py and client.py.
- Open the project in VS Code.
Verify your Python version
This project uses Python's built-in socket and threading modules. Both ship with Python and require no extra installation.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running this command:
python --version
✔️ I see Python 3.14
Your terminal shows something like Python 3.14.x. You are all set to continue.
ⓧ I see an older version
If you see a version lower than 3.14, your system may be pointing to an older Python installation.
- Try running python3 --version or python3.14 --version to see if a newer version is installed alongside the default.
- On macOS, the system Python is often outdated. If you installed Python 3.14 separately, the binary may live at /usr/local/bin/python3.14. You can also use pyenv to manage versions.
- If no 3.14 version is available, download it from python.org/downloads and re-run the version check after installing.
ⓧ Command not found
If your terminal does not recognize python, try python3 --version instead. Some systems only register the python3 alias.
If neither command works, Python is not installed on your machine.
- Download Python 3.14 from python.org/downloads.
- Run the installer with default settings.
- Close and reopen your terminal, then run python --version again.
Create the project folder and files
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir tcp-chat
cd tcp-chat
- Create the two empty files your project needs:
macOS
- Create both files by running:
touch server.py client.py
Windows
- Create both files by running:
New-Item server.py -ItemType File
New-Item client.py -ItemType File
- Confirm the files exist by running:
ls
You should see server.py and client.py in the output.
Don't see both files?
Make sure you ran the file-creation command after cd tcp-chat. If you ran it from ~/Desktop directly, the files ended up in the wrong folder.
If you see a "directory already exists" error on mkdir tcp-chat, the folder was created earlier. Just run cd tcp-chat and create the files inside it.
Help me find where my files ended up.
Open the project in VS Code
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the tcp-chat folder, then click Open.
You should see server.py and client.py listed in the Explorer panel on the left side of VS Code.
Don't see the Explorer panel?
If the left sidebar is hidden, press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to toggle the Explorer panel.
If you see an empty Explorer with no files, confirm you opened the tcp-chat folder itself and not the Desktop folder above it.
Help me get VS Code showing my project files.
Your workspace is ready. Next up, you will write a TCP server that accepts a connection and echoes messages back to the sender.
Build a Single-Client Echo Server
Your project folder is ready with both files waiting for code. Before building a multi-client chat system, you need to understand the basic TCP lifecycle first.
In this step, you will build the simplest possible TCP server. It accepts one connection, echoes back whatever it receives, and detects when the client disconnects. This echo server becomes the foundation you will expand into a full chat system.
In this step, get ready to:
- Write a TCP server that listens for one connection and echoes messages back.
- Write a test client that connects, sends a message, and prints the response.
- Run both programs to see the full TCP lifecycle in action.
Write the TCP server skeleton
A TCP server follows a specific sequence of calls to establish communication. It creates a socket, claims an address and port, then waits for clients to connect.
- In VS Code, open server.py from the Explorer sidebar.
- Add the server setup code by pasting the following:
import socket
# Server address configuration
HOST = "127.0.0.1"
PORT = 55555
# Create a TCP socket (AF_INET = IPv4, SOCK_STREAM = TCP)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Allow reuse of the address if the server restarts quickly
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
print(f"Server listening on {HOST}:{PORT}")
What does this code do?
socket.AF_INET specifies IPv4 addressing. socket.SOCK_STREAM specifies TCP, which provides a reliable, ordered stream of bytes.
setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) tells the OS to reuse the port immediately after the server stops. Without this, restarting the server quickly raises an "Address already in use" error.
bind() claims the address and port for this socket. listen() puts the socket into a state where it can accept incoming connections.
- Save server.py.
- Open a terminal in VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows).
- Run the script to confirm it starts without errors:
python server.py
You should see Server listening on 127.0.0.1:55555 printed, then the script exits immediately because there is no code to accept connections yet.
Seeing a SyntaxError or ModuleNotFoundError?
If you see a syntax error, double check that the f-string quotes match exactly. The print line uses an f prefix before the opening quote.
If python is not recognized, try python3 instead. Help me fix my server.py startup error
Now you need the code that actually handles an incoming connection. The server will wait for a client, then enter a loop where it echoes everything it receives.
- In server.py, add the following code below the print(f"Server listening on {HOST}:{PORT}") line:
# Wait for a client to connect
conn, addr = server.accept()
print(f"Connected by {addr}")
# Echo loop: receive data and send it back
while True:
data = conn.recv(1024)
if not data:
# Empty bytes means the client disconnected
print("Client disconnected.")
break
print(f"Received: {data.decode('utf-8')}")
conn.sendall(data)
conn.close()
server.close()
What does this code do?
accept() blocks (pauses execution) until a client connects. It returns a NEW socket (conn) just for that client, plus the client's address.
conn.recv(1024) reads up to 1024 bytes from the client. If it returns empty bytes (b""), the client has disconnected.
conn.sendall(data) sends the received data back to the client. sendall() handles the case where not all bytes fit into one network packet.
- Save server.py.
You won't see a change yet. The next substep builds a client to connect to this server.
Getting an indentation error?
Make sure the while True: block uses consistent indentation (4 spaces per level). The data = conn.recv(1024) line should be indented once inside the while loop.
Help me fix my indentation in server.py
✔️ Awesome, I've got everything!
Great. Make sure you have saved server.py before moving on.
ⓧ I'd like to double check the full code
import socket
HOST = "127.0.0.1"
PORT = 55555
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
print(f"Server listening on {HOST}:{PORT}")
conn, addr = server.accept()
print(f"Connected by {addr}")
while True:
data = conn.recv(1024)
if not data:
print("Client disconnected.")
break
print(f"Received: {data.decode('utf-8')}")
conn.sendall(data)
conn.close()
server.close()
Write the test client
A TCP client only needs two steps: create a socket and connect to the server's address. From there it can send and receive data freely.
- Open client.py in VS Code.
- Add the test client code by pasting the following:
import socket
# Must match the server's address
HOST = "127.0.0.1"
PORT = 55555
# Create a TCP socket and connect to the server
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
# Send a test message as raw bytes
sock.sendall(b"Hello, server!")
# Receive the echoed response
data = sock.recv(1024)
print(f"Echo: {data.decode('utf-8')}")
sock.close()
What does this code do?
sock.connect((HOST, PORT)) establishes the TCP connection to the server. This is the other half of the handshake that accept() was waiting for on the server side.
sock.sendall(b"Hello, server!") sends the message as raw bytes. The b prefix creates a bytes literal because sockets operate on bytes, not strings.
sock.recv(1024) waits for the server's response (the echoed message) and reads up to 1024 bytes.
- Save client.py.
You won't see a change yet. The next substep tests both programs together.
✔️ Awesome, I've got everything!
Great. Make sure you have saved client.py before moving on.
ⓧ I'd like to double check the full code
import socket
HOST = "127.0.0.1"
PORT = 55555
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
sock.sendall(b"Hello, server!")
data = sock.recv(1024)
print(f"Echo: {data.decode('utf-8')}")
sock.close()
Test the echo server
To test a server, you need two terminals running at the same time. One runs the server (waiting for connections), and the other runs the client (sending a message).
- In your existing terminal, start the server by running:
python server.py
You should see Server listening on 127.0.0.1:55555 and the terminal stays waiting. This time the server does not exit because accept() is blocking until a client connects.
- Open a second terminal by clicking the + icon in the VS Code terminal panel (or press Cmd+Shift+` on macOS, Ctrl+Shift+` on Windows).
- In the second terminal, run the client:
python client.py
In the client terminal, you should see:
Echo: Hello, server!
Switch back to the first terminal (click it in the terminal panel). The server should now show:
Connected by ('127.0.0.1', <some port number>)
Received: Hello, server!
Client disconnected.
What just happened?
You just saw the full TCP socket lifecycle in action. The server called socket(), bind(), listen(), and accept(). The client called socket() and connect().
Once connected, data flowed both ways with sendall() and recv(). When the client closed its socket, recv() on the server returned b"" (empty bytes), signaling the disconnect.
Client shows ConnectionRefusedError?
This means the server is not running. Make sure you started python server.py in the first terminal and it shows the "Server listening" message before you run the client.
If the server shows "Address already in use", a previous server process is still holding the port. Wait a few seconds or press Ctrl+C in any terminal that might have an old server running, then try again.
Help me debug my echo server connection
Your echo server proves that TCP communication works end to end. Next up, you will rewrite the client so it can stay connected and send multiple messages using threads.
Write the Chat Client with a Receive Thread
Your current client connects, sends one message, and immediately disconnects. A real chat client needs to stay connected indefinitely, sending and receiving messages at the same time.
The problem is that recv() blocks until data arrives. If you call it in the same thread as input(), the client freezes while waiting for a message. Threading solves this by running the receive logic in a background thread while the main thread handles user input.
In this step, get ready to:
- Write a receive function that listens for messages in a background thread.
- Write a main function that connects, sends a nickname, and loops on user input.
- Test the persistent client against the echo server from Step 2.
Write the receive function
The receive function runs in its own thread, continuously listening for data from the server. It needs to handle two failure cases: the server disconnecting cleanly (recv() returns empty bytes) and the socket being closed from the main thread (raises OSError).
- Clear the entire contents of client.py and replace it with the following code:
import socket
import threading
import sys
HOST = "127.0.0.1"
PORT = 55555
def receive(sock):
while True:
try:
data = sock.recv(1024)
if not data:
print("\nDisconnected from server.")
break
print(data.decode("utf-8"), end="")
except OSError:
break
sock.close()
What does this code do?
The imports bring in socket for networking, threading for running receive in the background, and sys for sys.exit() if the connection fails.
- sock.recv(1024) blocks until data arrives from the server. The 1024 is the maximum number of bytes to read at once.
- if not data catches the disconnect signal. When the server closes the connection, recv() returns b"" (empty bytes).
- The except OSError catches errors that happen when the main thread closes the socket while recv() is still waiting. Without this, the thread would crash with a traceback.
- print(data.decode("utf-8"), end="") prints incoming messages without adding an extra newline, since the messages already include one.
You won't see a change yet. The next chunk adds the main() function that actually connects and starts this thread.
Write the main function
The main() function handles everything the user interacts with: connecting to the server, asking for a nickname, launching the receive thread, and looping on keyboard input.
- Add the following code below the receive function in client.py:
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((HOST, PORT))
except ConnectionRefusedError:
print("Could not connect to server. Is it running?")
sys.exit(1)
nickname = input("Choose a nickname: ").strip()
if not nickname:
nickname = "Anonymous"
sock.sendall(nickname.encode("utf-8"))
receive_thread = threading.Thread(target=receive, args=(sock,), daemon=True)
receive_thread.start()
try:
while True:
message = input()
if message.lower() == "/quit":
break
sock.sendall(f"{message}\n".encode("utf-8"))
except (KeyboardInterrupt, EOFError):
pass
finally:
sock.close()
print("Disconnected.")
if __name__ == "__main__":
main()
What does this code do?
- sock.connect((HOST, PORT)) connects to the echo server. The try/except ConnectionRefusedError gives a friendly error if the server is not running.
- The nickname is sent as the first message. The server will use this to identify the client later (once we upgrade the server in the next step).
- threading.Thread(target=receive, args=(sock,), daemon=True) creates a background thread. Setting daemon=True means the thread will not prevent the program from exiting when the main thread finishes.
- The while True loop reads user input line by line. Typing /quit breaks the loop cleanly. Pressing Ctrl+C raises KeyboardInterrupt, which the except block catches.
- The finally block always runs, closing the socket regardless of how the loop ended.
Why use a daemon thread?
A daemon thread is automatically terminated when the main thread exits. Without daemon=True, the receive thread would keep the program alive even after you type /quit, because it would still be blocked on recv() waiting for data.
Marking it as a daemon lets the program exit cleanly when the main thread finishes its work.
- Save client.py.
Seeing a syntax error on save?
- Check that the def main(): line is at the top level (no indentation), not nested inside receive().
- Make sure there are no leftover lines from the old client.py at the bottom of the file.
- Verify the if __name__ == "__main__": line is at the very end of the file with no indentation.
help me fix a syntax error in my client.py chat client
Test with the echo server
Your client is ready to test. The echo server from Step 2 is still a single-client server, but it is enough to confirm the client can stay connected and handle multiple messages.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key).
- Start the server by running:
python server.py
- Open a second terminal by clicking the + icon in the terminal panel.
- Run the client:
python client.py
- When prompted, type a nickname and press Enter.
- Type a few messages and press Enter after each one.
- Confirm the server terminal prints each message you sent (prefixed with Received:) and the client terminal prints the echoed response.
- Type /quit to disconnect cleanly. You should see Disconnected. printed in the client terminal.
What should I see?
In the server terminal, you should see the connection announcement followed by each message you typed. In the client terminal, each message echoes back immediately, and you can keep typing without the client freezing.
The key difference from Step 2: the client stays connected for multiple messages instead of disconnecting after one.
Client freezes or no echo appears?
- Make sure the server is running before you start the client. If it is not running, you will see Could not connect to server. Is it running? instead.
- If you see Address already in use when starting the server, wait a few seconds for the OS to release port 55555, then try again.
- On some systems, python is not recognized. Try python3 instead.
help me debug my TCP chat client not receiving echoed messages
✔️ Awesome, I've got everything!
Great. Double check you have saved client.py and that your test worked before moving on.
ⓧ I'd like to double check the full code
Here is the complete client.py at this point:
import socket
import threading
import sys
HOST = "127.0.0.1"
PORT = 55555
def receive(sock):
while True:
try:
data = sock.recv(1024)
if not data:
print("\nDisconnected from server.")
break
print(data.decode("utf-8"), end="")
except OSError:
break
sock.close()
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((HOST, PORT))
except ConnectionRefusedError:
print("Could not connect to server. Is it running?")
sys.exit(1)
nickname = input("Choose a nickname: ").strip()
if not nickname:
nickname = "Anonymous"
sock.sendall(nickname.encode("utf-8"))
receive_thread = threading.Thread(target=receive, args=(sock,), daemon=True)
receive_thread.start()
try:
while True:
message = input()
if message.lower() == "/quit":
break
sock.sendall(f"{message}\n".encode("utf-8"))
except (KeyboardInterrupt, EOFError):
pass
finally:
sock.close()
print("Disconnected.")
if __name__ == "__main__":
main()
Your client now stays connected and handles messages concurrently. Next up, you will upgrade the server to handle multiple clients at once and broadcast messages between them.
Upgrade to a Multi-Client Broadcast Server
Your echo server works, but it can only handle one client at a time. A real chat server needs to accept many clients and relay messages between all of them simultaneously.
In this step, you will restructure server.py into a multi-client broadcast server. Each time a client connects, the server spawns a dedicated thread to handle that client's messages and forward them to everyone else.
In this step, get ready to:
- Rewrite the server to track multiple clients in a shared dictionary.
- Build a broadcast function that relays messages to all connected clients.
- Spawn a thread per client so the server handles everyone concurrently.
Restructure the server for multiple clients
The current server accepts one connection, echoes data, and exits. You need to replace that entire logic with a structure that keeps track of many clients at once.
- Open server.py in VS Code.
- Select all the existing code and delete it. You will rebuild the file from scratch.
- Add the module-level setup code by pasting the following:
import socket
import threading
HOST = "127.0.0.1"
PORT = 55555
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
# Dictionary mapping each client socket to its nickname
clients = {}
# Lock to protect the clients dict from concurrent thread access
clients_lock = threading.Lock()
print(f"Server listening on {HOST}:{PORT}")
What does this code do?
The socket setup is the same as before. The new additions are clients (a dictionary that maps each connected socket to that client's nickname) and clients_lock (a threading lock).
Multiple threads will read and write to clients at the same time. Without the lock, Python could raise a RuntimeError if one thread modifies the dictionary while another is iterating over it.
Next you need a function that sends a message to every connected client except the sender. This is the core of a chat server.
- Add the broadcast function below the setup code:
def broadcast(message, sender_socket=None):
with clients_lock:
disconnected = []
for client_socket in clients:
if client_socket is not sender_socket:
try:
client_socket.sendall(message)
except OSError:
disconnected.append(client_socket)
for client_socket in disconnected:
del clients[client_socket]
What does this code do?
The broadcast function iterates over every socket in the clients dictionary and sends the message to each one, skipping the sender so they do not see their own message echoed back.
If sendall() raises an OSError, it means that client disconnected between the iteration check and the send attempt. The function collects those dead sockets and removes them after the loop finishes.
Now you need the function that handles a single client's entire session: receiving the nickname, broadcasting a join message, and relaying all subsequent messages.
- Add the handle_client function below broadcast:
def handle_client(conn, addr):
# First message from client is always their nickname
nickname = conn.recv(1024).decode("utf-8").strip()
if not nickname:
conn.close()
return
with clients_lock:
clients[conn] = nickname
print(f"{nickname} connected from {addr}")
broadcast(f"{nickname} has joined the chat!\n".encode("utf-8"), conn)
# Relay loop: receive messages and broadcast to everyone else
while True:
data = conn.recv(1024)
if not data:
break
message = f"[{nickname}] {data.decode('utf-8')}"
print(message.strip())
broadcast(message.encode("utf-8"), conn)
What does this code do?
Each client handler runs in its own thread. The first recv() call grabs the nickname the client sends immediately after connecting. The handler then registers the client in the shared dictionary and tells everyone else that a new user joined.
The while True loop continuously receives data. Each message gets formatted with the sender's nickname in brackets and broadcast to all other clients. When recv() returns empty bytes, it means the client disconnected, so the loop breaks.
- Save server.py.
Seeing a syntax error?
Make sure you have two blank lines between each function definition (one blank line after the last line of the previous function, then another before the def keyword). Also check that your f-string quotes are consistent. The message = f"[{nickname}] {data.decode('utf-8')}" line uses double quotes on the outside and single quotes inside the decode() call.
Still stuck? Help me fix a syntax error in my multi-client server.py broadcast or handle_client function.
Make the main server loop spawn a thread per client
The server needs to continuously accept new connections and hand each one off to a dedicated thread. This way the main thread is always free to accept the next client.
- Add the main() function and the entry-point guard at the bottom of server.py:
def main():
try:
while True:
# Block here until a new client connects
conn, addr = server.accept()
# Spawn a thread dedicated to this client
thread = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
thread.start()
except KeyboardInterrupt:
print("\nServer shutting down.")
finally:
server.close()
if __name__ == "__main__":
main()
What does this code do?
The server.accept() call blocks until a client connects. Each time one does, the server creates a new daemon thread pointing at handle_client and starts it immediately. Then the loop goes right back to waiting for the next connection.
Setting daemon=True means these threads will not prevent the program from exiting when you press Ctrl+C. The KeyboardInterrupt handler catches that signal and shuts down the server socket cleanly.
- Save server.py.
Not sure about indentation?
The while True loop is inside the try block, so it needs two levels of indentation (8 spaces). The except and finally clauses align with the try at one level of indentation (4 spaces).
Need help? Check my main() function indentation in server.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved server.py before moving on to testing.
ⓧ I'd like to double check the full code
Here is the complete server.py file. Compare your file against this:
import socket
import threading
HOST = "127.0.0.1"
PORT = 55555
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
clients = {}
clients_lock = threading.Lock()
print(f"Server listening on {HOST}:{PORT}")
def broadcast(message, sender_socket=None):
with clients_lock:
disconnected = []
for client_socket in clients:
if client_socket is not sender_socket:
try:
client_socket.sendall(message)
except OSError:
disconnected.append(client_socket)
for client_socket in disconnected:
del clients[client_socket]
def handle_client(conn, addr):
nickname = conn.recv(1024).decode("utf-8").strip()
if not nickname:
conn.close()
return
with clients_lock:
clients[conn] = nickname
print(f"{nickname} connected from {addr}")
broadcast(f"{nickname} has joined the chat!\n".encode("utf-8"), conn)
while True:
data = conn.recv(1024)
if not data:
break
message = f"[{nickname}] {data.decode('utf-8')}"
print(message.strip())
broadcast(message.encode("utf-8"), conn)
def main():
try:
while True:
conn, addr = server.accept()
thread = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
thread.start()
except KeyboardInterrupt:
print("\nServer shutting down.")
finally:
server.close()
if __name__ == "__main__":
main()
Test with multiple clients
The real test of a chat server is seeing messages flow between multiple clients in real time. You need at least three terminal windows for this: one for the server and two for clients.
- If you still have the server running from earlier, stop it with Ctrl+C.
- In your first terminal, start the new server by running:
python server.py
- You should see Server listening on 127.0.0.1:55555 printed.
- Open a second terminal window. Start a client by running:
python client.py
- When prompted, enter a nickname like Alice.
- Open a third terminal window. Start another client by running the same command:
python client.py
- Enter a different nickname like Bob.
- In Alice's terminal, type a message and press Enter.
- Switch to Bob's terminal. You should see Alice's message prefixed with [Alice].
- Type a message in Bob's terminal and confirm it appears in Alice's terminal prefixed with [Bob].
What should I see?
The server terminal prints each message as it gets relayed. Alice's terminal shows Bob's messages, and Bob's terminal shows Alice's. Neither client sees their own messages echoed back. If you check the server, you will see join notifications for both clients followed by each message.
Client connects but messages do not appear?
Make sure your client.py sends the nickname as its very first message. The server expects the first recv() to be the nickname. If the client sends a chat message first, it gets stored as the nickname and nothing broadcasts correctly.
Also confirm you are running the updated server.py (the multi-client version, not the old echo server). Stop the server with Ctrl+C and restart it.
Still stuck? Help me debug why messages are not broadcasting between my chat clients.
Your server now handles multiple clients chatting in real time. There is one problem left to solve: if a client disconnects abruptly, the server could crash. Next up, you will add graceful disconnect handling to keep the server rock-solid.
Handle Disconnects Gracefully
Your multi-client server can broadcast messages between connected clients. But what happens when a client disappears? If someone closes their terminal or their connection drops, the server's recv() call either returns empty bytes or raises an exception. Without handling these cases, the server crashes and takes every other client down with it.
In this step, you will make the server resilient to disconnects. The handle_client function gets wrapped in a try/except/finally block that catches crashes, cleans up the client from the dictionary, and announces their departure to everyone else.
In this step, get ready to:
- Add disconnect detection and error handling to handle_client.
- Verify that broadcast() already handles failed sends gracefully.
- Test graceful and abrupt disconnect scenarios.
Add disconnect detection to handle_client
A client can disconnect in two ways. A graceful disconnect (the client calls close() or types /quit) makes recv() return empty bytes b"". An abrupt disconnect (the user kills the terminal window) raises a ConnectionResetError. You need to handle both.
- In server.py, find the handle_client function.
- Replace the entire handle_client function with this updated version:
def handle_client(conn, addr):
try:
nickname = conn.recv(1024).decode("utf-8").strip()
if not nickname:
conn.close()
return
with clients_lock:
clients[conn] = nickname
print(f"{nickname} connected from {addr}")
broadcast(f"{nickname} has joined the chat!\n".encode("utf-8"), conn)
while True:
data = conn.recv(1024)
if not data:
break
message = f"[{nickname}] {data.decode('utf-8')}"
print(message.strip())
broadcast(message.encode("utf-8"), conn)
except ConnectionResetError:
pass
finally:
with clients_lock:
nickname = clients.pop(conn, "Unknown")
print(f"{nickname} disconnected from {addr}")
broadcast(f"{nickname} has left the chat.\n".encode("utf-8"))
conn.close()
What does this code do?
- The try block wraps the entire client lifecycle: receiving the nickname, broadcasting join, and looping on incoming messages.
- When recv() returns b"" (graceful close), the while loop breaks and execution falls through to finally.
- When a client crashes without calling close(), Python raises ConnectionResetError. The except block catches this silently, then finally runs the cleanup.
- The finally block always runs: it removes the client from the clients dictionary with clients.pop(conn, "Unknown"), broadcasts a leave message, and closes the socket.
- Using pop(conn, "Unknown") with a default value prevents a KeyError if the client disconnected before their nickname was registered.
- Save server.py.
Why try/except/finally instead of just if-checks?
The if not data: break check only handles the graceful case where the client closes cleanly. If a client's machine loses network or the process is killed, the OS sends a TCP RST packet instead of a clean FIN. Python translates that RST into a ConnectionResetError exception.
Without the except block, that exception propagates up and kills the handler thread. The finally block guarantees cleanup runs no matter which exit path the code takes.
Verify that broadcast() handles failed sends
There is a race condition to consider: between the moment you check whether a client is in the dictionary and the moment you call sendall(), that client might disconnect. Your broadcast() function already handles this.
- In server.py, scroll up to the broadcast function and find this pattern:
try:
client_socket.sendall(message)
except OSError:
disconnected.append(client_socket)
Why catch OSError here?
If a client disconnects between the start of the broadcast loop and the sendall() call, the send raises an OSError. Instead of crashing the broadcast, the function catches the error and adds that socket to a disconnected list. After the loop finishes, it removes those stale sockets from the dictionary.
This means every message broadcast is a self-healing operation. Even if handle_client has not yet detected the disconnect, broadcast() catches and cleans it up.
Test graceful and abrupt disconnects
Time to prove your server can survive anything a client throws at it. You will test three scenarios: a clean quit, a killed terminal, and continued chatting after someone leaves.
- Open three terminal windows.
- Start the server in the first terminal by running:
python server.py
- Start a client in the second terminal by running:
python client.py
- Enter a nickname like Alice when prompted.
- Start another client in the third terminal by running python client.py and entering a nickname like Bob.
- Send a message from Alice to confirm broadcast is working.
Now test the disconnect scenarios:
- In Alice's terminal, press Ctrl+C to disconnect gracefully.
- Check the server terminal. You should see a line showing Alice disconnected.
- Check Bob's terminal. You should see a message saying Alice has left the chat.
- Start a new client (Charlie) in Alice's terminal.
- Close Charlie's terminal window entirely (abrupt disconnect) instead of using Ctrl+C.
- Check the server terminal again. You should see Charlie's disconnect logged without any crash or traceback.
- Send a message from Bob to confirm the server is still running and responsive.
Server crashed on disconnect?
- Make sure the entire body of handle_client (from the first recv() to the end of the while loop) is indented inside the try: block. If any recv() call sits outside try, it will not be caught.
- Verify that except ConnectionResetError is at the same indentation level as try. A misaligned except is a syntax error.
- Check that finally: comes after the except block, not inside it.
Help me debug why my server crashes when a client disconnects.
✔️ Awesome, I've got everything!
Great. Double check you have saved server.py and that your test showed leave notifications without any crashes.
ⓧ I'd like to double check the full code
Here is the complete server.py at this point:
import socket
import threading
HOST = "127.0.0.1"
PORT = 55555
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
clients = {}
clients_lock = threading.Lock()
print(f"Server listening on {HOST}:{PORT}")
def broadcast(message, sender_socket=None):
with clients_lock:
disconnected = []
for client_socket in clients:
if client_socket is not sender_socket:
try:
client_socket.sendall(message)
except OSError:
disconnected.append(client_socket)
for client_socket in disconnected:
del clients[client_socket]
def handle_client(conn, addr):
try:
nickname = conn.recv(1024).decode("utf-8").strip()
if not nickname:
conn.close()
return
with clients_lock:
clients[conn] = nickname
print(f"{nickname} connected from {addr}")
broadcast(f"{nickname} has joined the chat!\n".encode("utf-8"), conn)
while True:
data = conn.recv(1024)
if not data:
break
message = f"[{nickname}] {data.decode('utf-8')}"
print(message.strip())
broadcast(message.encode("utf-8"), conn)
except ConnectionResetError:
pass
finally:
with clients_lock:
nickname = clients.pop(conn, "Unknown")
print(f"{nickname} disconnected from {addr}")
broadcast(f"{nickname} has left the chat.\n".encode("utf-8"))
conn.close()
def main():
try:
while True:
conn, addr = server.accept()
thread = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
thread.start()
except KeyboardInterrupt:
print("\nServer shutting down.")
finally:
server.close()
if __name__ == "__main__":
main()
And here is the complete client.py (unchanged from the previous step):
import socket
import threading
import sys
HOST = "127.0.0.1"
PORT = 55555
def receive(sock):
while True:
try:
data = sock.recv(1024)
if not data:
print("\nDisconnected from server.")
break
print(data.decode("utf-8"), end="")
except OSError:
break
sock.close()
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((HOST, PORT))
except ConnectionRefusedError:
print("Could not connect to server. Is it running?")
sys.exit(1)
nickname = input("Choose a nickname: ").strip()
if not nickname:
nickname = "Anonymous"
sock.sendall(nickname.encode("utf-8"))
receive_thread = threading.Thread(target=receive, args=(sock,), daemon=True)
receive_thread.start()
try:
while True:
message = input()
if message.lower() == "/quit":
break
sock.sendall(f"{message}\n".encode("utf-8"))
except (KeyboardInterrupt, EOFError):
pass
finally:
sock.close()
print("Disconnected.")
if __name__ == "__main__":
main()
Secret mission
Rebuild the Chat Over UDP
Your TCP chat works perfectly. Messages always arrive, always in order. But what if you stripped all of that away? In this mission, you rebuild the entire messaging layer over UDP to experience firsthand why TCP exists and what you lose without it.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on 127.0.0.1, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files or delete them entirely.
Resources you used:
- The tcp-chat project folder on your Desktop (contains server.py, client.py, udp_server.py, and udp_client.py).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your chat server or extend it further.
- Your tcp-chat folder stays on your Desktop. You can reopen it in VS Code any time.
- If port 55555 or 55556 appears occupied when you restart a server, wait 30 seconds for the OS to release it, or change the PORT variable to a different number.
Pause - I'll come back to this later
Stop any running server or client processes to free up your terminal windows. Your files stay on disk.
- In each terminal window running a server or client, press Ctrl+C to stop the process.
- Close the terminal windows.
- Your tcp-chat folder and all four Python files remain on your Desktop for when you return.
Delete - I don't want to use this again
Remove all project files from your machine. Nothing else to clean up since everything ran locally.
- Stop any running server or client processes by pressing Ctrl+C in each terminal window.
- Delete the project folder by running the command for your operating system.
macOS
rm -rf ~/Desktop/tcp-chat
Windows
rmdir /s /q %USERPROFILE%\Desktop\tcp-chat
- Confirm the folder is gone by checking your Desktop.
That's a Wrap!
That's a Wrap!
Nice work! You just built a real-time multi-client chat system from scratch using nothing but Python's raw socket primitives.
You've learned how to:
- Build a TCP server from raw sockets and execute the full socket lifecycle: socket(), bind(), listen(), accept(), recv(), and sendall().
- Handle multiple simultaneous clients using a thread-per-connection architecture with a shared clients dictionary protected by a threading.Lock.
- Implement graceful disconnect handling by detecting empty bytes from recv() and catching ConnectionResetError for abrupt client crashes.
- Rebuilt the entire chat system over UDP and experienced firsthand why TCP exists for reliable messaging (Secret Mission).
Ready to quiz yourself?