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.

PHASE 3 — Topic 11: Sending Data Both Ways — Client to Server and Server to Client

We have already sent data in both directions in earlier examples. In this post, we slow down and look specifically at the different ways data can flow, since Socket.IO gives you more options than a single client just talking to a single server.

The Four Basic Directions

Every real Socket.IO application uses some combination of these four patterns:

  1. Client sends data to the server
  2. Server sends data back to that one specific client
  3. Server sends data to every connected client
  4. Server sends data to every client except the sender

Let's go through each one with real code.

1. Client to Server

This is the simplest direction, the client emits an event, and the server listens for it.

Client:


    socket.emit("sendMessage", { text: "Hello", sender: "Gagan" });

Server:


    socket.on("sendMessage", (data) => {
        console.log(data.text, data.sender);
    });

Inside the connection handler, socket always represents that one specific client's connection, so anything you do with socket only affects that particular user.

2. Server to One Specific Client

If the server wants to reply only to the client that just sent something, it uses socket.emit(...) from inside that same connection handler:


    io.on("connection", (socket) => {
        socket.on("sendMessage", (data) => {
            socket.emit("messageReceived", { status: "delivered" });
        });
    });

Since socket refers to one exact connection, socket.emit(...) here only reaches that single client, nobody else on the server gets this event.

3. Server to Every Connected Client

Sometimes you want everyone to see an update, for example, a new chat message that all users should see. For this, you use io.emit(...) instead of socket.emit(...):


    io.on("connection", (socket) => {
        socket.on("sendMessage", (data) => {
            io.emit("newMessage", data);
        });
    });

Here, io represents the entire server, not one connection. io.emit(...) sends the event to absolutely every client currently connected, including the one who originally sent the message.

4. Server to Everyone Except the Sender

Often, you don't want to send an update back to the same person who triggered it, they already know, since they're the one who did it. For this, Socket.IO gives you socket.broadcast.emit(...):


    io.on("connection", (socket) => {
        socket.on("sendMessage", (data) => {
            socket.broadcast.emit("newMessage", data);
        });
    });

This sends the event to every client except the one represented by socket. This is extremely common in chat apps, you show the sender their own message instantly through local state in React, and use broadcast so the server does not also send it back to them separately.

A Very Common Real Pattern

Combining points 2 and 4 together is one of the most useful patterns you will use repeatedly:


    socket.on("sendMessage", (data) => {
        socket.emit("messageSent", { status: "ok" });
        socket.broadcast.emit("newMessage", data);
    });

Here, the sender gets a private confirmation that their message was received, while everyone else gets the actual new message. This avoids duplicate messages appearing for the sender, while still keeping everyone in sync.

Sending to a Specific Client by ID

Sometimes you don't have direct access to a client's socket object, but you know their socket ID (perhaps you stored it earlier, tied to a user account). In that case, you can target them directly:


    io.to(socketId).emit("privateNotification", { text: "You have a new order" });

This sends the event only to the client with that exact socket ID, useful for private notifications, direct messages, or admin actions targeting a specific user.

A Quick Reference Table

Code

Who Receives It

socket.emit(...)

Only this one client

io.emit(...)

Every connected client, including sender

socket.broadcast.emit(...)

Every client except the sender

io.to(socketId).emit(...)

Only the client with that specific socket ID

Client Side: Listening for Both Kinds of Events

On the client, you don't need to know or care which method the server used, you just listen normally:


  useEffect(() => {
    function onMessageSent(data: { status: string }) {
      console.log("My message status:", data.status);
    }

    function onNewMessage(data: { text: string; sender: string }) {
      console.log("New message from someone:", data);
    }

    socket.on("messageSent", onMessageSent);
    socket.on("newMessage", onNewMessage);

    return () => {
      socket.off("messageSent", onMessageSent);
      socket.off("newMessage", onNewMessage);
    };
  }, []);

Why This Matters for Real Apps

Understanding these four directions properly prevents two very common beginner mistakes:

  • Using io.emit(...) everywhere, which accidentally sends private data (like personal notifications) to every connected user instead of just one
  • Using socket.emit(...) for something meant to update everyone, which means other users never see the update in real time at all

Choosing the right one of these four patterns for each event is really the core skill of designing a Socket.IO application properly.

Summary

  • socket.emit(...) sends data to only the one client tied to that connection
  • io.emit(...) sends data to every connected client, including the sender
  • socket.broadcast.emit(...) sends data to every client except the sender, extremely common in chat-style features
  • io.to(socketId).emit(...) targets one specific client by their socket ID
  • Picking the correct direction for each event is essential to avoid data going to the wrong people, or not reaching the right people at all

In the next post, we cover handling connection and disconnection events properly, including cleanup logic and tracking online users.

PHASE 3 — Topic 10: Understanding Events — emit, on, and Custom Event Names

We already used emit and on in the last post to say hello. Now let's go deeper and understand the full power of events, since this is the core of everything you will build with Socket.IO.

The Basic Pattern

At its heart, Socket.IO communication always follows the same pattern:

  • One side sends an event using emit(eventName, data)
  • The other side listens for that event using on(eventName, callback)

    socket.emit("orderPlaced", { orderId: 101, item: "Pizza" });

    socket.on("orderPlaced", (data) => {
        console.log(data.orderId, data.item);
    });

You can send multiple pieces of data in one emit too, they just arrive as separate arguments:


    socket.emit("updateItem", "item-1", { name: "Updated Name" });

    socket.on("updateItem", (itemId, updatedData) => {
        console.log(itemId, updatedData);
    });

Naming Your Own Events

Socket.IO does not force any naming rules on you, you can name events almost anything you want. But a few reserved event names already have special meaning, and you should never use them for your own custom events:

  • connect
  • disconnect
  • connect_error
  • disconnecting
  • newListener
  • removeListener

Beyond these, everything else is yours to design. Good event names make a real difference once your app grows past a handful of events. A few practical conventions worth following:

  • Use camelCase, matching the rest of your TypeScript codebase: sendMessage, not send_message or SendMessage
  • Prefer clear, specific verbs over vague ones: userTyping is better than just typing
  • Group related events with a shared prefix when it helps: chat:message, chat:typing, chat:userJoined. This is optional, but useful once you have many event categories in a large app
  • Keep client-to-server and server-to-client events named differently when they represent different actions, don't reuse message for both a request and a response, it gets confusing fast

Whatever convention you pick, the important part is staying consistent across your whole project, since this directly maps to the ServerToClientEvents and ClientToServerEvents interfaces we set up earlier.

Acknowledgements: Getting a Response Back

Sometimes a plain emit is not enough, you want to know that the other side actually received and processed your event, almost like a normal function call with a return value. Socket.IO supports this using acknowledgements.

You do this by passing a callback function as the last argument of emit:


    socket.emit("updateItem", "item-1", { name: "Updated" }, (response) => {
        console.log(response.status);
    });

On the receiving side, that same callback becomes an extra argument in your on handler:


    socket.on("updateItem", (itemId, updatedData, callback) => {
        console.log(itemId, updatedData);
        callback({ status: "ok" });
    });

Once callback(...) is called on the receiving side, it triggers the function you passed on the sending side, with whatever data you passed into it. This gives you a proper request-response feel, on top of Socket.IO's normal event-based communication.

Adding a Timeout to Acknowledgements

Normally, if the other side never calls the acknowledgement callback, your emit's callback simply never fires, it just waits forever. You can protect against this using .timeout():


    socket.timeout(5000).emit("updateItem", "item-1", { name: "Updated" }, (err, response) => {
        if (err) {
            console.log("No response within 5 seconds");
        } else {
            console.log(response.status);
        }
    });

If no acknowledgement arrives within 5000 milliseconds, err will be set, letting you handle the failure case properly instead of waiting indefinitely.

Volatile Events

Sometimes you don't actually care if an event gets lost, for example, sending a player's live position in a fast-paced game, where only the latest position matters anyway. For cases like this, Socket.IO offers volatile emits:


    socket.volatile.emit("playerPosition", { x: 120, y: 340 });

A volatile event is simply dropped if the connection is not ready to send it at that exact moment, instead of being queued up and sent later. This avoids flooding the connection with outdated data once it reconnects.

Listening to Every Event: onAny

For debugging, or for building generic logging systems, Socket.IO also lets you listen to every incoming event at once, without specifying a name:


    socket.onAny((eventName, ...args) => {
        console.log("Received event:", eventName, args);
    });

This is very useful during development, since you can quickly see everything flowing through your connection without adding a separate listener for each event manually. Just remember to remove it in production code once you're done debugging, using socket.offAny().

Putting It Together: A Small Realistic Example

Let's say you're building a "mark item as favorite" feature. Here is how emit, on, and acknowledgements might work together for it, add this to your existing src/types/socket.ts:


    export interface ClientToServerEvents {
        sendMessage: (data: { text: string; sender: string }) => void;
        sayHello: () => void;
        toggleFavorite: (
            itemId: string,
            callback: (response: { success: boolean }) => void
        ) => void;
    }

Server side, inside server.ts:


    socket.on("toggleFavorite", (itemId, callback) => {
        console.log("Toggling favorite for item:", itemId);
        callback({ success: true });
    });

Client side, wherever you handle the button click:


    socket.emit("toggleFavorite", "item-42", (response) => {
        if (response.success) {
            console.log("Favorite toggled successfully");
        }
    });

Notice how this feels almost like calling a normal async function, even though it's fully event-driven underneath.

Summary

  • emit sends an event with a name and optional data, on listens for that same event name
  • Custom event names are entirely up to you, but avoid Socket.IO's reserved names like connect and disconnect
  • Acknowledgements let you get a response back from the other side, just like a request-response call
  • .timeout() protects your acknowledgements from waiting forever if no response comes
  • Volatile emits are useful when losing an occasional event is acceptable, such as fast-changing position data
  • onAny is a handy debugging tool to see every event flowing through your connection

In the next post, we go further into sending data both ways, covering more real patterns for client-to-server and server-to-client communication in a typical application.

PHASE 3 — Topic 9: Connecting a Client to the Server — Your First Real-Time "Hello World"

Everything so far was setup. In this post, we finally connect the browser to the server and see a real message travel between them.

Step 1: Where the Socket Instance Lives

We already created src/lib/socket.ts back when we set up the client types. Here it is again, as a reminder of where our connection lives:


    import { io, Socket } from "socket.io-client";
    import type {
        ServerToClientEvents,
        ClientToServerEvents,
    } from "@/types/socket";

    export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io({
        path: "/api/socket",
    });

Keeping this in its own file is important. If you called io() directly inside a component, React could create a new connection every time that component re-renders. By creating the socket once here and importing the same instance everywhere, every part of your app shares one single connection.

Step 2: Add a "hello" Event to Our Types

Open src/types/socket.ts and add one new event on each side, just for this test:


    export interface ServerToClientEvents {
        message: (data: { text: string; sender: string }) => void;
        hello: (text: string) => void;
    }

    export interface ClientToServerEvents {
        sendMessage: (data: { text: string; sender: string }) => void;
        sayHello: () => void;
    }

    export interface InterServerEvents {
        ping: () => void;
    }

    export interface SocketData {
        userId: string;
    }

Step 3: Handle These Events on the Server

Open server.ts, and add these two lines inside your existing io.on("connection", ...) block:


    io.on("connection", (socket) => {
        console.log("A client connected:", socket.id);

        socket.on("sayHello", () => {
            socket.emit("hello", "Hello from the server!");
        });

        socket.on("sendMessage", (data) => {
            console.log("Received:", data.text, "from", data.sender);
        });

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

When a client sends sayHello, the server immediately emits hello back to that exact same client, with a text message as the payload.

Step 4: Build a Simple Client Component

Replace the content of src/app/page.tsx with this:


  "use client";

  import { useEffect, useState } from "react";
  import { socket } from "@/lib/socket";

  export default function Home() {
    const [isConnected, setIsConnected] = useState(false);
    const [message, setMessage] = useState("");

    useEffect(() => {
      function onConnect() {
        setIsConnected(true);
      }

      function onDisconnect() {
        setIsConnected(false);
      }

      function onHello(text: string) {
        setMessage(text);
      }

      socket.on("connect", onConnect);
      socket.on("disconnect", onDisconnect);
      socket.on("hello", onHello);

      return () => {
        socket.off("connect", onConnect);
        socket.off("disconnect", onDisconnect);
        socket.off("hello", onHello);
      };
    }, []);

    function handleSayHello() {
      socket.emit("sayHello");
    }

    return (
      <main className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gray-900 text-white">
        <p>
          Status:{" "}
          <span className={isConnected ? "text-green-400" : "text-red-400"}>
            {isConnected ? "Connected" : "Disconnected"}
          </span>
        </p>

        <button
          onClick={handleSayHello}
          className="rounded bg-blue-600 px-4 py-2 hover:bg-blue-700"
        >
          Say Hello to Server
        </button>

        {message && <p className="text-gray-300">{message}</p>}
      </main>
    );
  }

Step 5: Understanding This Code, Piece by Piece

  • "use client" is required at the top, since this component uses hooks and browser-only code, Server Components cannot do that
  • Inside useEffect, we define named functions (onConnect, onDisconnect, onHello) instead of inline arrow functions, so we can properly remove them later
  • socket.on(...) registers listeners when the component mounts
  • The return () => {...} cleanup function removes those listeners when the component unmounts, using socket.off(...). This is important, without cleanup, listeners would pile up every time this component re-mounts
  • Clicking the button calls socket.emit("sayHello"), which triggers the handler we wrote on the server
  • When the server responds with hello, our onHello listener updates the message state, and React re-renders the text on screen

Step 6: Test It

Run your app:


    npm run dev

Open http://localhost:3000. You should immediately see "Status: Connected" in green, since the socket connects automatically as soon as the page loads. Now click "Say Hello to Server". Within milliseconds, you should see "Hello from the server!" appear on the page.

Check your terminal too, you will see:


    A client connected: <some-socket-id>

Step 7: Why Cleanup Matters

Try removing the return () => {...} cleanup block temporarily, and reload the page a few times using hot reload during development. You will notice onHello gets called multiple times for a single click, since old listeners never got removed. This is one of the most common bugs beginners hit with Socket.IO in React, so always pair socket.on with a matching socket.off in your cleanup function.

Summary

  • We created a single shared socket instance in src/lib/socket.ts, imported wherever needed
  • We added a simple sayHello / hello event pair between client and server
  • We built a component that shows connection status, sends an event on button click, and displays the server's response
  • Cleanup with socket.off inside the useEffect return function is essential to avoid duplicate listeners

You now have a fully working real-time connection between Next.js and Socket.IO. In the next post, we go deeper into events, covering emit, on, and how to design good custom event names for a real application.

PHASE 4 — Topic 16: Broadcasting Messages to Everyone, or to Specific Users/Rooms

We've used several broadcasting patterns already across earlier posts. This post pulls everything together into one clear reference, and...