PHASE 3 — Topic 12: Handling Connection and Disconnection Events Properly

We have used connect and disconnect casually in earlier posts. In this post, we look at them properly, since handling these two events correctly is what separates a fragile real-time app from a reliable one.

Disconnections Are Normal, Not Errors

The first mindset shift beginners need: a disconnect is not a bug. It happens constantly, and for completely normal reasons:

  • The user closes the tab or refreshes the page
  • A mobile user switches from WiFi to mobile data
  • The server restarts during a deployment
  • A network briefly drops a packet
  • The connection is simply idle and gets recycled by an intermediate proxy

Because of this, your app must always be built expecting disconnects and reconnects to happen, not treating them as a rare failure case.

The disconnect Event on the Server

Every socket connection fires a disconnect event when it goes away. The handler also receives a reason string, telling you exactly why it happened:


  socket.on("disconnect", (reason) => {
    console.log(`Client ${socket.id} disconnected. Reason: ${reason}`);
  });

Some common reason values you will see:

  • "transport close" — the connection was closed, often the user closing the tab or losing network
  • "client namespace disconnect" — the client called socket.disconnect() on purpose
  • "server namespace disconnect" — the server called socket.disconnect() on that client
  • "ping timeout" — the client did not respond to a ping in time, likely a dead or very slow connection
  • "transport error" — something went wrong at the connection level, like a proxy or network issue

Knowing the reason helps you debug real issues later. For example, if you see many "ping timeout" disconnects in production, that often points to network instability rather than a bug in your code.

The disconnecting Event: Cleanup Before It's Too Late

Socket.IO also provides a disconnecting event, fired slightly before disconnect. The difference matters: at this point, the socket's room memberships are still available. By the time disconnect fires, the socket has already left every room it was in.


  socket.on("disconnecting", (reason) => {
    for (const room of socket.rooms) {
      if (room !== socket.id) {
        socket.to(room).emit("userLeft", { socketId: socket.id });
      }
    }
  });

This is the correct place to notify other users in a room that someone left, since trying to do this inside disconnect would be too late, socket.rooms would already be empty there.

Tracking Online Users

A very common real-world requirement is knowing who is currently online. A simple way to do this is with a Map, storing user data against their socket ID:


  const onlineUsers = new Map<string, { userId: string; username: string }>();

  io.on("connection", (socket) => {
    socket.on("userJoined", (userData) => {
      onlineUsers.set(socket.id, userData);
      io.emit("onlineUsersUpdated", Array.from(onlineUsers.values()));
    });

    socket.on("disconnect", () => {
      onlineUsers.delete(socket.id);
      io.emit("onlineUsersUpdated", Array.from(onlineUsers.values()));
    });
  });

Every time someone joins or leaves, we update the map and broadcast the new list to everyone. This is a simple approach that works well for smaller apps. For larger, multi-server setups, this same idea is usually backed by Redis instead of an in-memory Map, which we will cover in Phase 6.

An Important Warning About Socket IDs

It might feel tempting to treat socket.id as a stable identifier for a user, but this is a common mistake. socket.id is regenerated every single time a client reconnects, and two different browser tabs from the same user get two completely different IDs. If you refresh the page, your "online users" list would show a stale entry unless you handle it properly through disconnect cleanup, like we just did above.

For anything that needs to persist across reconnections, such as linking a socket back to a real user account, use a proper user ID or session ID instead, usually sent through the connection's auth payload, not the socket ID itself. We will cover this properly in Phase 5, when we handle authentication.

Client-Side Reconnection Behavior

On the client, Socket.IO automatically tries to reconnect after a disconnect, in most cases, without you writing any extra code. But it is still useful to listen for these events, so your UI can reflect the real connection state:


  useEffect(() => {
    function onDisconnect(reason: string) {
      console.log("Disconnected:", reason);
      setIsConnected(false);
    }

    function onReconnect(attemptNumber: number) {
      console.log("Reconnected after", attemptNumber, "attempts");
      setIsConnected(true);
    }

    socket.on("disconnect", onDisconnect);
    socket.io.on("reconnect", onReconnect);

    return () => {
      socket.off("disconnect", onDisconnect);
      socket.io.off("reconnect", onReconnect);
    };
  }, []);

Notice reconnect is listened to through socket.io, not directly on socket. This is because reconnection is managed by the underlying Engine.IO manager, not the Socket.IO socket itself.

One Case Where Reconnection Does Not Happen Automatically

There is one specific disconnect reason where the client will not try to reconnect on its own: "io server disconnect". This happens when the server explicitly calls socket.disconnect() on a client, for example, if you are banning a user. In this case, you must manually reconnect if that's actually what you want:


  socket.on("disconnect", (reason) => {
    if (reason === "io server disconnect") {
      socket.connect();
    }
  });

Every other disconnect reason triggers Socket.IO's normal automatic reconnection behavior on its own.

Summary

  • Disconnections are a normal, expected part of any real-time application, not an error condition
  • The disconnect event includes a reason, which is useful for debugging real connection issues
  • disconnecting fires before room memberships are cleared, making it the correct place to notify others that a user is leaving a room
  • A simple Map can track online users, updated on both join and disconnect
  • socket.id is not a stable user identifier, it changes on every reconnect and differs across tabs
  • The client automatically reconnects after most disconnects, except when the server explicitly disconnects it, which requires a manual socket.connect() call

This completes Phase 3. In the next post, we begin Phase 4 by building a simple, complete real-time chat application using everything we have learned so far, styled with Tailwind.

No comments:

Post a Comment

PHASE 4 — Topic 13: Building a Simple Real-Time Chat Application (UI with Tailwind)

Time to put everything from Phase 3 together into one complete, working feature: a real chat application. This post combines connection hand...