Skip to Content
Course content

416: Building a Simple Chat Application with Sockets

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Sockets are where the "magic" of the internet starts to feel like actual plumbing. If you've only worked with high-level APIs or HTTP requests, you're used to the request-response cycle: you ask for something, the server gives it to you, and the connection closes. But for a chat application, we need a persistent, two-way street. I've seen a lot of developers struggle with this because they try to apply that same request-response logic to a real-time stream.

The "One-and-Done" Server

When people first dive into the socket module, they usually write something like this. They create a socket, bind it to an address, and enter a loop to receive data. It looks clean on paper, but in practice, it's fundamentally broken for a chat app.

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('localhost', 5555))
server.listen()

print("Server is listening...")
conn, addr = server.accept() # The server stops here until someone connects

while True:
    data = conn.recv(1024).decode('utf-8')
    if not data:
        break
    print(f"Received: {data}")
    conn.send("Message received!".encode('utf-8'))

conn.close()

The problem here is that conn.recv() is a blocking call. Your server is literally frozen, staring at that one connection, waiting for bytes to arrive. If you're building a private 1-on-1 line, this is fine. But the moment a second person tries to join your "Dev Team Watercooler" chat, they'll be stuck in the queue. The server can't go back to server.accept() because it's trapped in the while True loop with the first user. It's a sequential bottleneck that makes the "chat" part of the application impossible.

Breaking the Block with Threading

To make this actually work for a group, we have to stop thinking sequentially. We need the server to do two things at once: keep listening for new people wanting to join, and keep listening to the people who are already there. In Python, the most straightforward way to handle this for a simple app is using the threading module.

Instead of handling the communication in the main loop, we treat each new connection as a separate task. When accept() returns a new connection, we hand that connection off to a dedicated thread and immediately jump back to listening for the next person. I usually recommend a "handler" function to keep the logic clean.

import socket
import threading

clients = []

def handle_client(conn, addr):
    print(f"[NEW CONNECTION] {addr} connected.")
    connected = True
    while connected:
        try:
            msg = conn.recv(1024).decode('utf-8')
            if not msg:
                break
            # Broadcast the message to everyone else
            for client in clients:
                if client != conn:
                    client.send(msg.encode('utf-8'))
        except ConnectionResetError:
            break
    
    clients.remove(conn)
    conn.close()
    print(f"[DISCONNECTED] {addr} left the chat.")

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('localhost', 5555))
server.listen()

while True:
    conn, addr = server.accept()
    clients.append(conn)
    # We spawn a new thread so the main loop can return to .accept()
    thread = threading.Thread(target=handle_client, args=(conn, addr))
    thread.start()

The Cost of Concurrency

Now we have a real chat app, but we've traded one problem for another. By using threads, we're now dealing with shared state—the clients list. If two people join at the exact same millisecond, or if someone leaves while the server is iterating through the list to broadcast a message, you might hit a RuntimeError because the list changed size during iteration.

In a production-grade system, I'd tell you to use asyncio or a message broker like Redis. asyncio is generally more efficient because it doesn't rely on the OS to switch between threads; it handles the "waiting" internally. However, for a learning project or a small internal tool, threading is much easier to reason about. You just have to be mindful of those shared lists. If this were a larger app, I'd wrap the clients.append and clients.remove calls in a threading.Lock() to ensure only one thread touches the list at a time.

One last thing: notice the try/except block around recv(). In the naive version, if a client just closed their laptop, the server might crash with a ConnectionResetError. When you're dealing with networks, you have to assume the other end is going to disappear without saying goodbye.




📋 Practical Task

Exercise: Implementing a Global Broadcast Announcement System

You have the basic multi-threaded server from the lesson, but it only allows users to talk to each other. Your task is to modify the server so it can handle "Admin Commands."

Modify the handle_client function so that if a user sends a message starting with the prefix /broadcast (e.g., /broadcast Server restarting in 5 minutes!), the server strips the prefix and sends that message to every connected client, including the sender, with a prefix of [ADMIN]: .

Requirements:

  • The server must not crash if a user sends an empty message.
  • Regular messages (without the prefix) should still be broadcast to everyone except the sender.
  • The admin broadcast must reach everyone.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.