PHASE 5 — Topic 20: Error Handling and Validating Data Sent Through Sockets

We ended the last post with a warning: TypeScript types do not protect you at runtime. This post covers the actual safety net, validating incoming data properly, and handling errors without crashing your server.

Why This Matters More Than It Seems

Your ClientToServerEvents interface tells TypeScript what a sendMessage payload should look like. But that interface only exists at compile time, it gets erased completely once your code runs. A malicious or broken client can send absolutely anything over the wire, a missing field, a number where you expect a string, a message with 50,000 characters, and your server will happily try to process it unless you check.

Installing Zod

We'll use Zod, a widely used schema validation library that pairs naturally with TypeScript, since it can generate a matching type directly from a schema.


    npm install zod

Defining a Validation Schema

Create a new file, src/lib/validation.ts:


    import { z } from "zod";

    export const chatMessageSchema = z.object({
        text: z.string().trim().min(1).max(500),
        sender: z.string().trim().min(1).max(50),
    });

    export type ValidatedChatMessage = z.infer<typeof chatMessageSchema>;

    export const joinRoomSchema = z.string().trim().min(1).max(50);

  • .min(1) rejects empty strings, catching someone sending a blank message
  • .max(500) prevents extremely long payloads from being processed or broadcast
  • .trim() strips accidental leading/trailing whitespace before the length checks run
  • z.infer<typeof chatMessageSchema> gives you a TypeScript type automatically derived from the schema itself, so your validation rules and your types never drift apart

Validating Inside an Event Handler

Zod's .safeParse() never throws, it returns a result object you check manually, which fits naturally into Socket.IO's callback-style handlers:


    socket.on("chatMessage", (data) => {
        const result = chatMessageSchema.safeParse(data);

        if (!result.success) {
            socket.emit("errorMessage", "Invalid message format");
            return;
        }

        const validatedData = result.data;

        io.emit("newMessage", {
            id: `${socket.id}-${Date.now()}`,
            text: validatedData.text,
            sender: socket.data.username,
            timestamp: Date.now(),
        });
    });

Notice we use socket.data.username from our authenticated session, not validatedData.sender, tying back to the authentication post, the sender identity should always come from something you verified, not from data the client freely typed into the payload.

Add a New Event for Error Messages

Update src/types/socket.ts to include this new event we just used:


    export interface ServerToClientEvents {
        message: (data: { text: string; sender: string }) => void;
        hello: (text: string) => void;
        newMessage: (data: ChatMessage) => void;
        onlineCount: (count: number) => void;
        roomMessage: (data: ChatMessage) => void;
        errorMessage: (message: string) => void;
    }

And listen for it on the client:


    useEffect(() => {
        function onErrorMessage(message: string) {
            console.error("Server error:", message);
        }

        socket.on("errorMessage", onErrorMessage);

        return () => {
            socket.off("errorMessage", onErrorMessage);
        };
    }, []);

Validating Acknowledgement-Based Events

The same pattern applies when an event expects a response back, like toggleFavorite from Topic 10:


    socket.on("toggleFavorite", (itemId, callback) => {
        const result = z.string().min(1).safeParse(itemId);

        if (!result.success) {
            callback({ success: false, error: "Invalid item ID" });
            return;
        }

        callback({ success: true });
    });

Here, instead of emitting an error event, we pass the failure back through the acknowledgement callback itself, keeping the error tied directly to the specific request that failed.

Catching Unexpected Runtime Errors

Validation handles bad input. It does not handle unexpected failures inside your own logic, for example, a database call that throws. Wrap handler logic in a try/catch so one failing event does not crash your entire server process:


    socket.on("chatMessage", async (data) => {
        try {
            const result = chatMessageSchema.safeParse(data);

            if (!result.success) {
                socket.emit("errorMessage", "Invalid message format");
                return;
            }

            io.emit("newMessage", {
                id: `${socket.id}-${Date.now()}`,
                text: result.data.text,
                sender: socket.data.username,
                timestamp: Date.now(),
            });
        } catch (err) {
            console.error("Unexpected error in chatMessage handler:", err);
            socket.emit("errorMessage", "Something went wrong");
        }
    });

A single unhandled exception inside an event handler will not necessarily crash your entire Node.js process the way an uncaught error elsewhere might, but it can still leave things in an inconsistent state, or silently fail without telling the client anything went wrong. The try/catch guarantees the client always gets a response, success or failure, instead of the request silently disappearing.

Handling Connection-Level Errors

Beyond individual events, listen for connect_error on the client to catch failures happening before a connection is even established, such as our authentication middleware from Topic 18 rejecting a bad token:


    socket.on("connect_error", (err) => {
        console.error("Connection failed:", err.message);
    });

A General Rule Worth Adopting

Every event handler that receives data from a client should follow the same shape:

  1. Validate the incoming data with a schema
  2. Return early with an error response if validation fails
  3. Only then use the validated, typed data
  4. Wrap the core logic in try/catch for anything that could fail unexpectedly

Following this consistently across your entire server is what actually closes the gap that plain TypeScript types leave open.

Summary

  • TypeScript types are erased at runtime and cannot stop malformed or malicious data from reaching your handlers
  • Zod schemas validate incoming data at runtime, and can also generate matching TypeScript types automatically
  • .safeParse() returns a result object instead of throwing, fitting naturally into event handler logic
  • Sender identity should come from authenticated socket.data, never from client-supplied payload fields
  • Acknowledgement callbacks should return validation failures directly, keeping errors tied to the specific request
  • Wrapping handler logic in try/catch ensures a client always gets a response, even when something fails unexpectedly

This completes Phase 5. In the next post, we begin Phase 6 by looking at why Socket.IO needs special handling when you scale to multiple servers, before we introduce the Redis Adapter.

PHASE 5 — Topic 19: TypeScript Best Practices for Socket.IO (Typed Events, Typed Payloads)

We have been using typed events since Phase 2, but scattered across many posts. This post consolidates everything into a set of clear best practices, and adds a few refinements worth adopting before moving forward.

Practice 1: Enable Strict Mode, Fully

If your tsconfig.json does not already have this, add it now. This is the single most impactful setting for catching Socket.IO-related bugs early:


    {
        "compilerOptions": {
            "strict": true,
            "noUncheckedIndexedAccess": true
        }
    }

strict enables strictNullChecks, noImplicitAny, and several other checks together. noUncheckedIndexedAccess is not part of strict, but is worth adding separately, it makes accessing something like onlineUsers[0] return T | undefined instead of just T, which catches a very common class of bugs when working with arrays of connected users or messages.

Practice 2: Never Use any for Event Payloads

It can be tempting, especially under deadline pressure, to type a payload as any just to make an error go away. Avoid this entirely for Socket.IO events. The whole point of the typed event interfaces we built in Phase 2 is that a payload shape mismatch gets caught while you're writing the code, not after a user hits a broken feature in production. If you genuinely need an escape hatch for something external, unknown is safer than any, since it still forces you to narrow the type before using it.

Practice 3: Keep One Source of Truth for Event Types

We already followed this pattern from the start, a single src/types/socket.ts file, imported by both client and server. This is worth calling out explicitly as a rule, not just something we happened to do: never redeclare event interfaces separately on the client and server. If they drift apart, even slightly, TypeScript can no longer catch mismatches between what one side sends and what the other expects, defeating the entire purpose of typing this in the first place.

Practice 4: Use Discriminated Unions for Complex Payloads

Sometimes a single event can carry genuinely different kinds of data depending on context. Instead of making every field optional, which weakens type safety, use a discriminated union:


    type NotificationPayload =
        | { type: "message"; text: string; sender: string }
        | { type: "friendRequest"; fromUserId: string }
        | { type: "systemAlert"; message: string; severity: "info" | "warning" };

    export interface ServerToClientEvents {
        notification: (data: NotificationPayload) => void;
    }

On the receiving side, TypeScript can narrow the type automatically based on the type field:


    socket.on("notification", (data) => {
        if (data.type === "message") {
            console.log(data.sender, data.text);
        } else if (data.type === "friendRequest") {
            console.log(data.fromUserId);
        }
    });

This is far safer than one event interface with five optional fields, where nothing tells you which fields actually belong together.

Practice 5: Type Acknowledgement Callbacks Properly

Recall the acknowledgement pattern from Topic 10. It is easy to leave the callback's response type loose. Type it explicitly instead:


    export interface ClientToServerEvents {
        toggleFavorite: (
            itemId: string,
            callback: (response: { success: boolean; error?: string }) => void
        ) => void;
    }

This ensures both the code that calls the callback on the server, and the code that receives its result on the client, agree exactly on what that response object looks like.

Practice 6: Type socket.data Precisely, and Extend It as Needed

We set up SocketData back in Phase 2 with a single userId field, and extended it further in the authentication post. As your app grows, keep this interface as the single place describing everything you attach to a connection over its lifetime:


    export interface SocketData {
        userId: string;
        username: string;
        role: "user" | "admin";
    }

Avoid attaching ad hoc properties to socket.data that are not declared here, doing so silently breaks the type safety this interface is meant to provide.

Practice 7: Remember These Types Are Compile-Time Only

This is worth repeating from Topic 8, since it matters even more now that our types have grown more advanced. Every practice in this post improves your experience while writing code, autocomplete, caught typos, enforced payload shapes. None of it validates data arriving at runtime from an actual client, especially one that might not even be running your TypeScript client code at all, for example, a malicious script talking directly to your WebSocket endpoint. The next post covers proper runtime validation and error handling, which is the actual safety net your types cannot provide on their own.

A Quick Before-and-After

Untyped, the kind of code you might write without any of this:


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

Typed, following the practices above:


    socket.on("sendMessage", (data) => {
        io.emit("newMessage", {
            id: `${socket.id}-${Date.now()}`,
            text: data.text,
            sender: socket.data.username,
            timestamp: Date.now(),
        });
    });

In the second version, data is already known to be { text: string; sender: string } from ClientToServerEvents, no any, no manual checking of whether data.text even exists, and your editor autocompletes every property.

Summary

  • Enable strict mode and noUncheckedIndexedAccess in tsconfig.json, this catches the most bugs for the least effort
  • Never type event payloads as any; use unknown if you truly need an escape hatch
  • Keep event interfaces in one shared file, imported by both client and server, never redeclared separately
  • Use discriminated unions for events that can carry genuinely different payload shapes
  • Type acknowledgement callback responses explicitly, not just the initial event data
  • Keep SocketData as the single, precise definition of everything attached to a connection
  • Compile-time types improve developer experience but do not replace runtime validation, covered next

In the next post, we cover error handling and validating data sent through sockets, the runtime safety net that complements everything we just built with TypeScript.

PHASE 5 — Topic 18: Authentication — Making Sure Only Logged-In Users Can Connect

So far, anyone who knows your app's URL can open a Socket.IO connection. For a real application, this is a problem. In this post, we secure the connection itself using JWT-based authentication, checked before a client is even allowed to connect.

Where Authentication Happens: Middleware

Socket.IO supports middleware at the connection level, using io.use(). This runs once, for every socket, before the connection event fires. If the middleware rejects the connection, the client never actually connects, it receives a connect_error instead.


    io.use((socket, next) => {
        next();
    });

Calling next() with no argument allows the connection. Calling next(new Error("message")) rejects it.

Sending Credentials From the Client

The client sends authentication data through the auth option when creating the connection, not through a normal event:


    import { io } from "socket.io-client";

    const socket = io({
        path: "/api/socket",
        auth: {
            token: userToken,
        },
    });

This auth object is sent once, during the initial handshake, and is available on the server as socket.handshake.auth.

Writing the Authentication Middleware

Here is a complete JWT-based middleware, assuming you already have a JWT issued elsewhere in your app, for example, after a normal login form:


    import jwt from "jsonwebtoken";

    interface DecodedToken {
        userId: string;
        username: string;
    }

    io.use((socket, next) => {
        const token = socket.handshake.auth?.token;

        if (!token) {
            return next(new Error("Authentication error: token required"));
        }

        try {
            const decoded = jwt.verify(
                token,
                process.env.JWT_SECRET as string
            ) as DecodedToken;

            socket.data.userId = decoded.userId;
            next();
        } catch (err) {
            next(new Error("Authentication error: invalid token"));
        }
    });

A few important details here:

  • socket.handshake.auth?.token reads the token the client sent
  • jwt.verify() throws if the token is invalid or expired, which we catch and reject with next(new Error(...))
  • On success, we store the decoded user ID on socket.data, this is exactly the SocketData interface we defined back in Phase 2, so this assignment is fully typed
  • Since socket.data persists for the lifetime of the connection, every event handler later can access socket.data.userId without re-verifying the token each time

Installing jsonwebtoken


    npm install jsonwebtoken
    npm install --save-dev @types/jsonwebtoken

Handling the Rejection on the Client

If the middleware rejects the connection, the client receives a connect_error event, with the error message you passed to next():


    socket.on("connect_error", (err) => {
        console.log("Connection failed:", err.message);
    });

This is a good place to redirect a user back to a login page, or show an error message, if their token is missing or has expired.

Using the Authenticated User ID in Your Events

Once middleware succeeds, socket.data.userId is available everywhere inside that connection's handlers, so you no longer need clients to tell you who they are, you already know, and can trust it:


    io.on("connection", (socket) => {
        socket.join(`user:${socket.data.userId}`);

        socket.on("chatMessage", (data) => {
            io.emit("newMessage", {
                id: `${socket.id}-${Date.now()}`,
                text: data.text,
                sender: socket.data.userId,
                timestamp: Date.now(),
            });
        });
    });

This is a meaningful improvement over our earlier chat app, which trusted whatever sender name the client sent in the message payload itself. That approach let anyone impersonate anyone. Now, the sender identity comes from the verified token, not from client-supplied data.

Authenticating Specific Namespaces Only

Recall from Topic 15 that namespaces support their own middleware. This lets you apply authentication selectively, for example, requiring auth only on an /admin namespace, while keeping your public / namespace open:


    const adminNamespace = io.of("/admin");

    adminNamespace.use((socket, next) => {
        const token = socket.handshake.auth?.token;

        if (!token) {
            return next(new Error("Authentication error"));
        }

        try {
            const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as {
                userId: string;
                role: string;
            };

            if (decoded.role !== "admin") {
                return next(new Error("Not authorized"));
            }

            next();
        } catch {
            next(new Error("Authentication error"));
        }
    });

A Note on connectionStateRecovery

Back in the previous post, we saw the skipMiddlewares option under connectionStateRecovery. Now this makes more sense in context: if skipMiddlewares is true, a reconnecting client skips this authentication middleware entirely during recovery. This is convenient, since a user's brief WiFi drop won't force a full re-authentication, but it also means a user whose token expired, or whose access was revoked, during that disconnection window could still reconnect without being re-checked. For anything security-sensitive, consider setting skipMiddlewares: false, accepting the small cost of re-running authentication on every reconnect.

Applying This to Our Chat App

For our course project, add the middleware shown above to server.ts, right before the io.on("connection", ...) block. On the client, replace the random username we generated earlier with a real token obtained from wherever your app handles login, and pass it through the auth option when creating the socket in src/lib/socket.ts.

Summary

  • io.use() registers middleware that runs before a connection is accepted, and can reject it using next(new Error(...))
  • The client sends credentials through the auth option during connection setup, accessible on the server as socket.handshake.auth
  • JWT verification inside this middleware is the standard way to authenticate a Socket.IO connection
  • Verified user data should be stored on socket.data, so event handlers can trust it instead of relying on client-supplied fields
  • Namespaces can have their own separate authentication middleware, useful for admin-only or role-restricted sections
  • skipMiddlewares in connection state recovery is a trade-off between convenience and strict re-authentication on every reconnect

In the next post, we cover TypeScript best practices for Socket.IO in more depth, tightening up typed events and payloads across the whole project.

PHASE 5 — Topic 17: Handling Reconnection, Offline Users, and Connection State Recovery

We touched on reconnection basics back in Topic 12. Now we go deeper, into what actually happens to your data during a disconnect, and how Socket.IO can automatically restore it.

The Real Problem With Reconnection

Automatic reconnection solves getting the connection back. It does not, by itself, solve what happens to the data that was sent while the client was disconnected. Consider our chat app: if a user's WiFi drops for 10 seconds, and two messages get sent during that gap, a plain reconnect brings the socket back online, but those two messages are simply gone for that user. Nobody stored them anywhere for later delivery.

This is exactly the gap that Connection State Recovery was built to close.

What Connection State Recovery Actually Does

When enabled, Socket.IO temporarily stores a disconnected client's state on the server, so that on reconnection, it can restore:

  • The socket's id
  • The rooms it had joined
  • Any custom data attached to the socket
  • Any events it missed while disconnected

Here's how it works internally, in plain terms:

  • During the handshake, the server assigns the client a private session ID, separate from the usual public socket.id
  • Every event sent to the client also carries a small offset marker
  • If the client disconnects, the server holds onto its state for a limited time
  • When the client reconnects, it sends back its session ID and the last offset it successfully received
  • If the server still has that session stored, it restores the rooms, data, and replays any events the client missed

Enabling It on the Server

This is a single option passed when creating the Socket.IO server:


    const io = new Server<
        ClientToServerEvents,
        ServerToClientEvents,
        InterServerEvents,
        SocketData
    >(httpServer, {
        path: "/api/socket",
        connectionStateRecovery: {
            maxDisconnectionDuration: 2 * 60 * 1000,
            skipMiddlewares: true,
        },
    });

  • maxDisconnectionDuration controls how long the server keeps a disconnected client's state around, in milliseconds. The default is 2 minutes. If the client does not reconnect within this window, the state is discarded permanently.
  • skipMiddlewares controls whether your connection middleware (which we will build in the next post) runs again during a recovery. Setting this to true lets a reconnecting client skip re-running those checks, which is convenient, but worth being careful with, since it also means a client who was, say, banned during their disconnection window could still reconnect without being re-checked. Use this setting thoughtfully.

Checking Whether Recovery Succeeded

On the server, inside your connection handler, socket.recovered tells you whether this connection is a fresh one or a restored one:


    io.on("connection", (socket) => {
        if (socket.recovered) {
            console.log("Session recovered:", socket.id);
        } else {
            console.log("New or unrecoverable session:", socket.id);
        }
    });

If socket.recovered is true, socket.rooms and socket.data are already restored automatically, you don't need to manually re-join rooms or reassign data yourself.

Checking It on the Client

The client has the same flag available:


    socket.on("connect", () => {
        console.log("Recovered?", socket.recovered);
    });

This is useful for deciding whether your UI needs to re-fetch fresh state from scratch, or whether it can trust that any missed events have already been replayed automatically.

Applying This to Our Chat App

Update server.ts to enable recovery, as shown above. Then, on the client, you can use socket.recovered to skip unnecessary re-initialization:


    socket.on("connect", () => {
        setIsConnected(true);

        if (!socket.recovered) {
            console.log("Fresh connection, state was not recoverable");
        }
    });

If a user's connection drops briefly and comes back within the recovery window, any chat messages broadcast during that gap will simply appear in their message list automatically, without you writing any manual "catch up" logic.

Important Limitations to Know

Connection state recovery is genuinely useful, but it is not a magic fix for all reconnection scenarios, and you should not treat it as one:

  • It is only designed for temporary disconnections, within the configured time window, not long absences
  • Storing missed packets is not compatible with the Redis Adapter's pub/sub mechanism, which we will cover in Phase 6, this matters if you scale to multiple servers later
  • Real-world testing has shown edge cases, for example, switching from WiFi to mobile data does not always trigger recovery reliably, since the old connection may not register as disconnected in time on the server
  • It only recovers what Socket.IO itself is aware of, rooms, socket data, and emitted events, it does not know about arbitrary application state you manage separately, like React state that was never sent through a socket event

Because of these limitations, connection state recovery should be treated as a helpful convenience layer, not a replacement for designing your application to handle reconnections and resynchronization properly at the data level.

Summary

  • Connection state recovery lets Socket.IO automatically restore a client's rooms, custom data, and missed events after a short disconnection
  • It is enabled with the connectionStateRecovery option on the server, controlling how long state is retained and whether middleware re-runs on recovery
  • socket.recovered, available on both server and client, tells you whether a connection was restored or is brand new
  • It only handles temporary disconnections within a configured time window, and has known limitations, such as unreliable behavior across network switches and incompatibility with Redis pub/sub
  • It should be treated as a convenience layer, not a substitute for proper resynchronization logic in your application

In the next post, we cover authentication, making sure only logged-in users can connect to your Socket.IO server in the first place.

PHASE 5 — Topic 20: Error Handling and Validating Data Sent Through Sockets

We ended the last post with a warning: TypeScript types do not protect you at runtime. This post covers the actual safety net, validating in...