PHASE 6 — Topic 24: Performance Tips and Common Mistakes to Avoid

This closes out Phase 6 with a practical checklist. Most of these mistakes are ones we already avoided by following good habits earlier in this course, this post makes each one explicit, so you recognize them immediately if you ever see them in someone else's code, or accidentally introduce one yourself.

Mistake 1: Not Cleaning Up Event Listeners

We covered this back in Topic 9, but it's worth repeating here because it remains one of the most common Socket.IO bugs in real applications. Every socket.on(...) you register in a React component needs a matching socket.off(...) in your useEffect cleanup function. Without it, listeners accumulate every time a component re-mounts, and the same event ends up handled multiple times, wasting memory and causing duplicate UI updates.


    useEffect(() => {
        socket.on("newMessage", handleMessage);

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

Mistake 2: Registering the Same Listener More Than Once

A closely related issue: registering an identical listener multiple times without realizing it, for example, calling socket.on(...) inside a function that itself gets called on every render, rather than inside useEffect. Each registration adds another listener, so a single incoming event fires your handler multiple times. If you ever notice a message appearing twice on screen for no clear reason, this is one of the first things worth checking.

Mistake 3: Doing Heavy Work Inside Event Handlers

Socket.IO event handlers run on your single Node.js event loop, the same one handling every other connection. If a handler does something computationally expensive, like processing a large payload synchronously, it blocks that entire event loop, delaying every other connected client's events at the same time, not just the one that triggered it. Keep handlers lean; offload genuinely heavy work to a background job or worker process instead of doing it inline.

Mistake 4: Sending Payloads Larger Than Necessary

Every socket connection carries context and payload overhead. Sending unnecessarily large objects, full user records when you only need a name, entire message histories instead of just the new message, adds up quickly across many connected clients. Keep event payloads minimal and specific to what the receiving side actually needs.

Mistake 5: Not Validating Data, Trusting the Client

We covered this in depth in Topic 20, but it belongs on this list because it remains one of the most common security-related mistakes in Socket.IO applications. Skipping runtime validation means a malicious or broken client can send malformed data directly to your handlers, bypassing whatever TypeScript types you defined at compile time.

Mistake 6: Forgetting CORS Configuration

If your Socket.IO server and your frontend end up on different origins, for example, during certain development or deployment setups, you need to explicitly configure CORS on the server, or the connection will be rejected before it even reaches your handlers:


    const io = new Server(httpServer, {
        path: "/api/socket",
        cors: {
            origin: process.env.CLIENT_URL || "http://localhost:3000",
            methods: ["GET", "POST"],
        },
    });

In our project, since Next.js and Socket.IO share the exact same server and origin, we haven't needed this. But if you ever split them into separate services, as mentioned back in Topic 23, this becomes necessary.

Mistake 7: Not Monitoring Connection Counts and Memory

In production, it's easy to lose track of how many clients are actually connected, and how much memory your server process is using over time, until something goes wrong. Basic observability tools, like Prometheus for metrics or simple heap snapshot monitoring, help catch a slow memory leak or a runaway connection count before it becomes an outage rather than after.

Mistake 8: Ignoring Reconnection Storms

If your server restarts, or briefly goes down, every connected client tries to reconnect at roughly the same time. On top of that, if your reconnection logic doesn't include proper backoff, this creates a sudden spike of connection attempts hitting your server all at once, right as it's coming back up, which can itself cause further instability. Socket.IO's default client already includes exponential backoff for reconnection attempts, so as long as you haven't overridden that behavior with a custom, aggressive retry loop, you're already protected from this by default.

Mistake 9: Keeping Timeouts Too Generic

Different users have very different network conditions. Region, mobile connections, and general internet quality can vary drastically. If your acknowledgement timeouts and reconnection intervals from Topic 10 use one fixed, aggressive value everywhere, users on slower connections may see failures that faster-connection users never experience. It's worth reconsidering these values against your actual user base, rather than leaving default or arbitrary numbers in place indefinitely.

A Practical Checklist Before Shipping

Before considering a Socket.IO feature production-ready, it's worth running through:

  • Every socket.on() in a React component has a matching socket.off() in cleanup
  • Event payloads are validated with a schema, not trusted blindly
  • Event payloads only contain the data actually needed, nothing extra
  • No heavy synchronous work happens directly inside an event handler
  • CORS is configured correctly, if client and server are on different origins
  • Basic monitoring exists for connection counts and memory usage

Applying This to Our Course Project

Looking back across this course, our chat app already follows nearly every practice on this list: cleanup functions since Topic 9, validation since Topic 20, minimal payloads throughout, and no heavy synchronous work in any handler. The one gap is observability, we haven't added any monitoring, which is reasonable for a learning project, but would be worth addressing before running something like this at real scale in production.

Summary

  • Uncleaned event listeners remain the single most common Socket.IO mistake, always pair socket.on with socket.off in cleanup
  • Keep event handlers lightweight; heavy synchronous work blocks the entire event loop for every connected client
  • Keep payloads minimal, validate them at runtime, and never trust client-supplied data blindly
  • Configure CORS explicitly if your client and server run on different origins
  • Basic monitoring of connection counts and memory usage helps catch problems before they become outages
  • Socket.IO's default reconnection behavior already includes backoff, avoid overriding it with an aggressive custom retry loop

This completes Phase 6. In the next and final post, we build one complete real-time project end-to-end, tying together everything from this entire course into a single, polished feature.

PHASE 6 — Topic 23: Deploying a Next.js + Socket.IO App (Hosting Options and Considerations)

We've covered why Socket.IO needs a persistent server, back in Topic 6. Now let's turn that into an actual deployment plan, and look at where our custom server can actually run in production.

Why Vercel Is Off the Table

  • This is worth stating plainly, since it surprises a lot of developers coming from a typical Next.js background. Vercel does not support custom servers. Since our entire project runs through server.ts, a custom Node.js server wrapping both Next.js and Socket.IO, we cannot deploy this specific setup to Vercel. This is not a Socket.IO limitation, it's a direct consequence of the custom server architecture we chose back in Phase 2.
  • If you want to deploy Next.js on Vercel and still use Socket.IO, the common pattern is to keep Next.js on Vercel and run a single, separate Node.js process for the socket layer on a different platform entirely, with your frontend connecting to it as a client. That's a valid architecture, but a different one from what we've built in this course. For our project, since Next.js and Socket.IO share one process, we need a host that runs long-lived Node.js servers.

Where Our App Can Actually Run

Next.js can be deployed as a Node.js server, Docker container, or adapted to run on different platforms, and can be deployed to any provider that supports Node.js or Docker containers. For a project with a custom server, the realistic options are platforms built around long-running processes rather than serverless functions:

  • Railway — Git-to-deploy workflow, straightforward for a project like ours
  • Render — similar developer experience to Railway, with Web Service deployment supporting Next.js, managed TLS through Let's Encrypt
  • Fly.io — runs your app on VMs, giving you persistent compute rather than spinning functions up and down, with strong control over deployment regions
  • A plain VPS or Docker container — full control, more setup responsibility

There isn't one universally "correct" choice here, it depends on your budget, how much infrastructure you want to manage yourself, and how much traffic you expect. For a learning project or a small production app, Railway or Render are usually the simplest starting points. For more control over regions and scaling behavior, Fly.io is a common choice among teams self-hosting Next.js.

Preparing the Build

Regardless of platform, the deployment flow follows the scripts we already set up back in Topic 7:


    npm run build
    npm run start

npm run build runs next build, producing the optimized production build. npm run start then runs our server.ts file with NODE_ENV=production, which serves that build through our combined Next.js + Socket.IO server.

A Basic Dockerfile

Most of these platforms deploy cleanly from a Dockerfile. Here's a minimal one suited to our project structure:


    FROM node:20-alpine

    WORKDIR /app

    COPY package.json package-lock.json ./
    RUN npm ci

    COPY . .
    RUN npm run build

    EXPOSE 3000

    CMD ["npm", "run", "start"]

This installs dependencies, builds the Next.js app, and starts the custom server on container startup. Environment variables, like JWT_SECRET or REDIS_URL from earlier posts, should be set through your hosting platform's environment variable settings, never committed into the Dockerfile or your repository.

Environment Variables to Set in Production

Based on everything we've built so far in this course, your production environment needs at minimum:


    NODE_ENV=production
    PORT=3000
    JWT_SECRET=your-production-secret
    REDIS_URL=redis://your-redis-host:6379

Never reuse a development JWT secret in production, and make sure your Redis instance, if you're using the adapter from the last post, is on a private network your app can reach, not exposed publicly, as we noted in Topic 22.

Connecting Sticky Sessions, If You Scale

If you eventually run multiple instances of this app behind a load balancer, revisit Topic 21: your hosting platform's load balancer needs sticky sessions enabled, so a client's requests consistently reach the same instance. Most platforms mentioned above support this through their load balancer configuration, though the exact settings differ per platform, so check your specific provider's documentation when you reach that point.

A Note on What You Give Up

A custom server also disables some automatic Next.js optimizations that assume the default server, and connection limits, memory management, health checks, and graceful shutdown all become your responsibility rather than being handled for you. This is the trade-off we accepted back in Topic 6, when we chose the combined custom server approach for this course. For a learning project, this is a completely reasonable trade-off. For a larger production application, it's worth revisiting occasionally whether splitting Socket.IO into its own dedicated service, as mentioned earlier in this post, makes more operational sense as your app grows.

Health Checks

Most hosting platforms expect a way to verify your app is alive. Since our server.ts already handles all incoming requests through Next.js, any normal page route works as a basic health check target, but it's often worth adding a dedicated, lightweight one:


    if (req.url === "/health") {
        res.writeHead(200);
        res.end("OK");
        return;
    }

Add this near the top of your request handler in server.ts, before requests are passed to Next.js, so your hosting platform can quickly confirm the server is responsive without going through the full Next.js rendering pipeline.

Summary

  • Our custom server architecture rules out Vercel, since it does not support custom servers; a Node.js-friendly host is required instead
  • Railway, Render, Fly.io, and plain VPS/Docker setups are all realistic choices, each with different trade-offs around simplicity and control
  • Deployment follows npm run build then npm run start, the same scripts we set up in Phase 2
  • Environment variables like JWT_SECRET and REDIS_URL belong in your platform's environment settings, never in your repository
  • If scaling to multiple instances, sticky sessions must be configured at your load balancer, on top of the Redis Adapter from the last post
  • A custom server takes on responsibilities Next.js normally handles automatically, including health checks and connection management

In the next post, we cover performance tips and common mistakes to avoid, closing out Phase 6 before we build the final project in Phase 7.

PHASE 6 — Topic 22: Using the Redis Adapter to Scale Socket.IO Across Multiple Instances

We understood the problem in the last post: multiple server instances can't broadcast to each other's clients by default. This post covers the solution, the Redis Adapter, and how to wire it into our project.

How the Redis Adapter Works, Conceptually

The Redis Adapter uses Redis's Pub/Sub mechanism as a shared messaging layer between server instances. Here's the flow for a broadcast, like io.to("room1").emit(...):

  1. The message is sent to all matching clients connected to the current server, exactly as before
  2. The same message is also published to a Redis channel
  3. Every other Socket.IO server instance, subscribed to that same channel, receives it, and relays it to its own matching connected clients

From your application code's perspective, nothing changes. You still write io.emit(...), io.to(room).emit(...), socket.broadcast.emit(...), exactly as we've been doing throughout this course. The adapter works transparently underneath, you don't rewrite your event logic to use it.

Installing the Required Packages


    npm install @socket.io/redis-adapter redis

This assumes a Redis server is available for your app to connect to, either running locally for development or hosted for production.

Setting Up the Adapter

Update server.ts to create Redis clients and attach the adapter:


    import { createClient } from "redis";
    import { createAdapter } from "@socket.io/redis-adapter";

    const pubClient = createClient({ url: process.env.REDIS_URL || "redis://localhost:6379" });
    const subClient = pubClient.duplicate();

    await Promise.all([pubClient.connect(), subClient.connect()]);

    const io = new Server<
        ClientToServerEvents,
        ServerToClientEvents,
        InterServerEvents,
        SocketData
    >(httpServer, {
        path: "/api/socket",
        adapter: createAdapter(pubClient, subClient),
    });

A few important details:

  • The adapter needs two separate Redis client connections, one dedicated to publishing (pubClient), one dedicated to subscribing (subClient). This is a Redis requirement, not a Socket.IO one, a single connection cannot both publish and subscribe at the same time
  • pubClient.duplicate() creates the second connection using the same configuration, so you only need to define the connection details once
  • Both clients must be connected, using await, before the adapter is created

Where This Code Fits in Our server.ts Structure

Since pubClient.connect() and subClient.connect() are asynchronous, and our existing server.ts structure uses app.prepare().then(...), place the Redis connection setup before creating the Socket.IO server, inside the same async flow:


    app.prepare().then(async () => {
        const httpServer = createServer((req, res) => {
            const parsedUrl = parse(req.url!, true);
            handle(req, res, parsedUrl);
        });

        const pubClient = createClient({ url: process.env.REDIS_URL || "redis://localhost:6379" });
        const subClient = pubClient.duplicate();
        await Promise.all([pubClient.connect(), subClient.connect()]);

        const io = new Server<
            ClientToServerEvents,
            ServerToClientEvents,
            InterServerEvents,
            SocketData
        >(httpServer, {
            path: "/api/socket",
            adapter: createAdapter(pubClient, subClient),
        });

        // ... rest of connection handling stays the same
    });

What the Adapter Does Not Solve

This is worth being explicit about, since it's a common misconception. The Redis Adapter solves broadcasting across servers. It does not solve the sticky sessions requirement we covered in the last post, you still need your load balancer configured for session affinity, since a single client's long-polling requests still need to consistently reach the same server instance. The adapter and sticky sessions solve two separate parts of the same overall problem.

A Limitation Worth Knowing: Connection State Recovery

Recall the connection state recovery feature from Topic 17. As of the current Redis Adapter, this feature is not supported when using it. If you need both multi-server scaling and connection state recovery together, you would need to look at the newer Redis Streams Adapter instead, which handles temporary Redis disconnections differently and is designed with this compatibility in mind. For most courses and small-to-medium production apps, the standard Redis Pub/Sub adapter shown here is the right starting point.

A Security Note Worth Taking Seriously

Messages passed through the Redis Adapter's Pub/Sub channels are not encrypted, signed, or authenticated by the adapter itself. Redis is meant to be treated as trusted internal infrastructure, not exposed to public networks. In production, this means proper Redis authentication, firewall rules, and ideally keeping Redis on a private network that your application servers can reach, but the public internet cannot.

Testing This Locally

To actually see the adapter working, you would need to run two instances of your server on different ports, both connected to the same Redis instance, then connect different browser tabs to each port separately. If a message sent through the instance on one port appears in a tab connected to the other port, the adapter is working correctly. Setting up this kind of local multi-instance testing environment is optional for this course, since our focus is understanding the concept and the code correctly, but it's a valuable exercise if you want to see it in action yourself.

Summary

  • The Redis Adapter uses Redis Pub/Sub so that a broadcast on one server instance also reaches clients connected to other instances
  • It requires two separate Redis connections, one for publishing and one for subscribing, created with pubClient.duplicate()
  • Your existing io.emit(), io.to(), and socket.broadcast.emit() code does not need to change, the adapter works transparently underneath
  • The adapter solves cross-server broadcasting, but does not replace the need for sticky sessions at your load balancer
  • The standard Redis Pub/Sub adapter does not support connection state recovery; the newer Redis Streams Adapter exists for cases needing both
  • Redis should be treated as trusted internal infrastructure, properly secured and not exposed to public networks

In the next post, we cover deploying a Next.js + Socket.IO app to production, including hosting options and what to look for given everything we've covered in this phase.

PHASE 6 — Topic 21: Why Socket.IO Needs Special Handling When You Scale to Multiple Servers

Everything we've built so far runs on one server process. This works great during development, and even for many small production apps. But once traffic grows enough that one server is not enough, Socket.IO introduces two problems that a typical stateless REST API does not have to deal with. This post explains both, before we solve them with the Redis Adapter in the next post.

Problem 1: In-Memory State Does Not Cross Server Boundaries

  • Think back to everything we built: the onlineUsers map, rooms created with socket.join(), the online count. All of this lives in the memory of one running Node.js process.
  • Now imagine you run two instances of your server, behind a load balancer, to handle more traffic. Client A connects and lands on Server 1. Client B connects and lands on Server 2. If Client A sends a chat message, and your code does io.emit("newMessage", data), that only reaches clients connected to Server 1, since io only knows about its own process's connections. Client B, sitting on Server 2, never receives it, even though both are technically using the same application.
  • This is the core problem: rooms, online user tracking, and broadcasts are all scoped to a single process by default. Running multiple server instances does not automatically make them aware of each other.

Problem 2: The Sticky Sessions Requirement

  • Recall from Phase 2 how a Socket.IO connection starts, with HTTP long-polling, and optionally upgrades to WebSocket afterward. Long-polling works through a sequence of separate HTTP requests, all tied together using a session ID.
  • Here is where multi-server setups create a subtle but serious issue. If a load balancer distributes each incoming HTTP request to a different server, purely round-robin style, a client's long-polling requests could land on a different server instance every time. Since each server instance has no idea about sessions that started on another instance, the connection breaks entirely.
  • The standard fix for this is called sticky sessions (also called session affinity). This means configuring your load balancer so that once a client's first request lands on a particular server, every following request from that same client keeps going to that exact same server, instead of being spread around.

Why Not Just Sync State Between Servers Automatically?

It's reasonable to wonder why Socket.IO doesn't just handle all of this transparently for you. Technically, it's possible to synchronize connection state between every server instance so that sticky sessions are not required at all. Socket.IO's own documentation is direct about why this isn't done by default: constantly synchronizing this state across every instance introduces a significant performance cost. So instead, sticky sessions remain the recommended approach, and any cross-server broadcasting is handled separately, through an adapter, which we cover next.

The Three Pieces of a Multi-Server Setup

Putting this together, a properly scaled Socket.IO deployment needs three things working together:

  1. A load balancer, distributing incoming connections across multiple server instances
  2. Sticky sessions, configured at the load balancer level, so a client's requests consistently reach the same server instance
  3. An adapter, so that when one server broadcasts an event, it actually reaches clients connected to every other server instance too, not just its own

Without all three, some part of your real-time functionality will quietly break under load, sometimes in ways that are hard to notice until you're already scaled up and users start reporting missing messages.

A Realistic Limit Worth Knowing

Even a single, well-optimized Node.js server has a practical ceiling on how many concurrent Socket.IO connections it can comfortably handle, generally somewhere in the range of tens of thousands, depending on your hardware and what else that process is doing. Beyond that point, things like memory pressure and event loop contention start degrading performance in ways that are hard to predict. This is exactly the point where scaling to multiple servers, and consequently dealing with the two problems in this post, becomes necessary rather than optional.

Where This Leaves Us

To summarize the gap: sticky sessions solve the connection routing problem, making sure a client consistently reaches the same server. But they do nothing to solve the broadcasting problem, a message from a client on Server 1 still cannot reach a client on Server 2 on its own. That second problem is solved by an adapter, and the most common choice for this is Redis, which is exactly what we cover in the next post.

Summary

  • Running multiple Socket.IO server instances breaks two things by default: in-memory state (rooms, online users) does not cross server boundaries, and broadcasts only reach clients on the same server that triggered them
  • Sticky sessions solve connection routing, ensuring a client's requests consistently land on the same server instance, which is required for long-polling to function correctly
  • Sticky sessions alone do not solve cross-server broadcasting, that requires a separate mechanism
  • A full multi-server setup needs a load balancer, sticky sessions, and an adapter working together
  • A single server has a practical connection ceiling, which is usually the actual trigger for needing to scale in the first place

In the next post, we introduce the Redis Adapter, the standard solution that lets multiple Socket.IO server instances broadcast events to each other's connected clients.

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.

Post 1.5 — Supabase CLI Basics: Local Development

Everything so far has used the hosted Supabase Cloud dashboard directly. That's fine for learning, but for real projects you want to dev...