Sitemap
Adam Morsi

Adam Morsi

One registry, many sockets: scaling socket.io in React and Redux Toolkit

10 min readOct 18, 2026

--

A scalable socket.io architecture for React apps that need many sockets at once: chat, AI per document and live background jobs.

Press enter or click to view image in full size

Most socket.io tutorials end with one line: const socket = io(URL) at the top of a file, imported wherever you need it. And for a single chat room, that's fine.

Then the product grows. You need an AI assistant that streams answers per document, a socket that reports the progress of a background extraction job, and a notification feed. Suddenly you have several sockets, some of them opened many times with different ids, all living next to each other in one React app.

That’s where the “one global socket” approach starts to crack, and the bugs it causes are the quiet kind that are hard to trace:

  • Two components call connect() and you end up with two connections to the same server.
  • A message handler fires twice because it was registered again after a re-render.
  • Your token expires, socket.io reconnects, and the server rejects it because it’s still sending the old token.
  • A promise around disconnect() or emit() never resolves, and your UI is stuck on "Sending..." forever.

In this article I’ll walk through the architecture I use in my open-source React + socket.io boilerplate: what each layer does, why it’s built that way, and the six subtle bugs it removes along the way.

The architecture at a glance

The whole idea fits in one sentence: every connection has a unique key, and one shared registry owns all of them.

Press enter or click to view image in full size
Every layer talks only to the one below it. Components never touch a socket directly.

From top to bottom:

  1. React components dispatch thunks and clean up when they unmount. They never import socket.io.
  2. Redux async thunks (createAsyncThunk) wrap every socket call, so you get pending, fulfilled and rejected states for free.
  3. Socket services are defined once in sockets.js, one per feature (chatSocket, aiChatSocket, extractionSocket).
  4. The socketClient registry holds every open connection in a Map<connectionKey, Socket>.

Let's build it from the bottom up.

Layer 1: the socketClient registry

The registry is a single class instance that stores three maps: the open sockets, the connections that are still in progress, and the event handlers. Everything is indexed by the connection key.

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

class SocketClient {
connections = new Map();
connectingPromises = new Map();
eventHandlers = new Map();

connect({ connectionKey, url, options = {} }) {
let socket = this.connections.get(connectionKey);

// Already connected
if (socket?.connected) return Promise.resolve();

// Already connecting: share the same promise
const pending = this.connectingPromises.get(connectionKey);
if (pending) return pending;

if (!socket) {
socket = io(url, { forceNew: true, ...options });
this.connections.set(connectionKey, socket);
// attach the handlers registered before connect()
this.getEventHandlers(connectionKey).forEach((handlers, event) =>
handlers.forEach((fun) => socket.on(event, fun))
);
} else if (!socket.active) {
// the server refused the last attempt, try again
socket.connect();
}

const promise = new Promise((resolve, reject) => {
const cleanup = () => {
socket.off('connect', onConnect);
socket.off('connect_error', onError);
this.connectingPromises.delete(connectionKey);
};
const onConnect = () => { cleanup(); resolve(); };
const onError = (error) => { cleanup(); reject(error); };

socket.on('connect', onConnect);
socket.on('connect_error', onError);
});

this.connectingPromises.set(connectionKey, promise);
return promise;
}
}

export const socketClient = new SocketClient();

Three details matter here:

  • Calling connect() twice is safe. If the key is already connected, it resolves immediately. If it's still connecting, every caller gets the same pending promise. You never get a second socket.
  • forceNew: true gives every key its own connection. By default socket.io shares one underlying connection per server URL, and connection-level options such as query are taken from the first socket that created it. With forceNew, each key gets exactly the options you passed.
  • The connect and connect_error listeners remove themselves. Without that cleanup, every reconnect attempt would leave two more listeners behind.

A handler registry that outlives the socket

Handlers are stored outside the socket. That small decision makes on() work no matter when it's called: before connect(), during the handshake or after it.

on(event, connectionKey, fun) {
const handlers = this.getEventHandlers(connectionKey);
const registered = handlers.get(event) ?? [];

// idempotent: the same handler is never attached twice
if (!registered.includes(fun)) {
registered.push(fun);
handlers.set(event, registered);
this.connections.get(connectionKey)?.on(event, fun);
}

return Promise.resolve();
}

When you call disconnect(connectionKey), the registry drops that key's handlers too. Otherwise, the next time the same key connects, it would bring back handlers that belong to a page the user already left.

Layer 2: connection keys

A connection key is just a string, but it's what makes "many sockets" possible. It combines a connection type with an optional room id.

Press enter or click to view image in full size
One service, many independent connections, each identified by its key.
export const SocketConnectionType = {
Chat: 'chat',
AiChat: 'ai_chat',
Extraction: 'extraction',
};

export const generateConnectionKey = (connectionType, roomId) => {
if (!connectionType) {
throw new Error('Connection type is required');
}

return roomId ? `${roomId}_${connectionType}` : connectionType;
};

A global chat only needs 'chat'. An AI assistant that can be open on three documents at once uses 'doc1_ai_chat', 'doc2_ai_chat' and 'doc3_ai_chat'. Each one can be connected, listened to and disconnected without touching the others.

Layer 3: socket services

Feature code shouldn't know about URLs, namespaces or auth. That's the job of createSocketService: a factory that binds a server, a namespace and an auth strategy, and exposes a small, consistent API.

export function createSocketService({
url = process.env.SOCKET_HOST,
namespace = '',
options = {},
auth,
canEmit = false,
} = {}) {
const service = {
connect(connectOptions) {
const { connectionKey, pathParams, query } = connectOptions;
const path = typeof namespace === 'function'
? namespace(pathParams)
: namespace;
const socketOptions = { ...options };

if (query) socketOptions.query = { ...options.query, ...query };

if (auth) {
// called by socket.io on EVERY (re)connect attempt
socketOptions.auth = typeof auth === 'function'
? (cb) => cb(auth(connectOptions))
: auth;
}

return socketClient.connect({
connectionKey,
url: `${url}${path}`,
options: socketOptions,
});
},
disconnect: (key) => socketClient.disconnect(key),
on: (event, key, fun) => socketClient.on(event, key, fun),
off: (event, key, fun) => socketClient.off(event, key, fun),
isConnected: (key) => socketClient.isConnected(key),
};

if (canEmit) {
service.emit = (key, event, data, config) =>
socketClient.emit({ connectionKey: key, event, data, ...config });
}

return service;
}

(Simplified for readability: the version in the repo also lets you override auth and options per connection.)

Get Adam Morsi’s stories in your inbox

Join Medium for free to get updates from this writer.

Two things to note:

  • The namespace can be a function. One service can build /ai-chat/doc1, /ai-chat/doc2 and so on from pathParams.
  • Auth is passed to socket.io as a callback, not as a value. socket.io calls it on every reconnect attempt, so a refreshed token is picked up automatically instead of replaying the one you had when the page loaded.

Defining your services then reads almost like configuration:

// sockets.js
export const chatSocket = createSocketService({ canEmit: true });

export const aiChatSocket = createSocketService({
namespace: (pathParams) => `/ai-chat/${pathParams?.documentId}`,
auth: () => ({ token: localStorage.getItem('token') }),
canEmit: true,
});

// listen-only: this service has no emit() at all
export const extractionSocket = createSocketService({
namespace: '/extraction',
});

Layer 4: Redux thunks and components

Every socket call goes through createAsyncThunk. You get loading and error states in Redux for free, and your components stay free of socket logic.

const connectionKey = generateConnectionKey(SocketConnectionType.Chat);

export const connectToSocket = createAsyncThunk(
'connectToSocket',
() => chatSocket.connect({ connectionKey })
);

export const sendMessage = createAsyncThunk(
'sendMessage',
({ message, username }) =>
chatSocket.emit(connectionKey, 'chat', { message, handle: username })
);

export const removeChatListeners = createAsyncThunk(
'removeChatListeners',
() => {
chatSocket.off('chat', connectionKey);
chatSocket.off('typing', connectionKey);
}
);

And the component registers its listeners when the socket is connected and always cleans them up:

useEffect(() => {
if (connectionStatus === 'connected') {
dispatch(fetchTypingUsername());
dispatch(fetchMessages());

return () => {
dispatch(removeChatListeners());
};
}
}, [connectionStatus, dispatch]);

What happens when you call connect()

Here's the full decision path in one picture. The important part is the right column: every "yes" answer avoids creating a new socket.

Press enter or click to view image in full size
Same key, same socket: calling connect() twice never opens a second connection.

Once the first connection succeeds, socket.io takes over: it reconnects with backoff and jitter, re-runs the auth callback on every attempt and buffers emits while it’s reconnecting. You don’t write any of that yourself.

Six bugs this design removes

These all existed in an earlier, simpler version of the boilerplate. None of them crashed anything, which is exactly why they’re worth knowing about.

1. A disconnect() that never resolves

It’s tempting to write socket.disconnect(() => resolve()). But socket.io's disconnect() is synchronous and doesn't accept a callback, so that promise hangs forever. Call it, clear your reference and resolve straight away.

2. A cleanup that reads stale state

A useEffect cleanup like if (connectionStatus === 'connected') disconnect() reads connectionStatus from the first render, when it was still empty. The disconnect never runs. Make disconnect() safe to call at any time and drop the condition.

3. Two connect() calls, two sockets

Calling io(url) on every connect() silently leaks the previous socket. The registry checks for an open socket and a pending promise first.

4. A token frozen at connect time

auth: { token: getToken() } is evaluated once. After the token rotates, every automatic reconnect sends the expired one. Pass a callback instead: auth: (cb) => cb({ token: getToken() }).

5. Listeners that pile up

Handlers registered in an effect without a cleanup get added again every time the effect re-runs. There’s also a subtle trap: socket.off(event, undefined) doesn't remove all listeners. It removes only the first one, because the argument count is 2. To remove them all, call socket.off(event) with one argument.

6. An emit waiting for an ack that never comes

Wrapping emit in a promise that resolves in the acknowledgement callback looks clean. But if the server never calls the ack (and most simple servers don't), the promise never settles and the UI stays on "Sending...". The fix: resolve as soon as the event is sent, and only wait for an ack when you ask for one, with a timeout:

emit({ connectionKey, event, data, ack = false, timeout = 5000 }) {
return new Promise((resolve, reject) => {
const socket = this.connections.get(connectionKey);
if (!socket) return reject(new Error('No socket connection'));
    if (!ack) {
socket.emit(event, data);
return resolve();
}
socket.timeout(timeout).emit(event, data, (err, response) => {
const error = err || response?.error;
return error ? reject(error) : resolve(response);
});
});
}

None of these bugs throws an error. They show up as a spinner that never stops, a message that appears twice, or a user who gets logged out of the chat after an hour. That’s why it pays to fix them in one place instead of in every feature.

Why not just use the native WebSocket?

It’s a fair question. The browser already has WebSocket, so why ship a library? Because with native WebSocket, everything socket.io does for you becomes your code:

  • Reconnecting with exponential backoff and jitter, and knowing when to stop.
  • A message format (for example { event, data } JSON) and routing messages to handlers.
  • Buffering messages while the connection is down.
  • Acknowledgements with timeouts.
  • Falling back to HTTP long-polling in environments where WebSocket is blocked.

You can absolutely write all of that, and large production apps sometimes do. But for most apps, it’s hundreds of lines of subtle code you’d rather not maintain.

One thing to keep in mind: socket.io-client only talks to a socket.io server. If your backend isn’t Node, you can still use it, because there are socket.io servers for Python, Java, Go and other languages. If your backend only speaks plain WebSocket, the nice part of this architecture is that only the registry needs to change. Services, thunks and components keep the same API. The repo’s README lists exactly what to replace.

Adding a new socket in three steps

This is where the architecture pays off. Adding a new real-time feature looks like this:

1. Define the service in sockets.js:

export const aiChatSocket = createSocketService({
namespace: (pathParams) => `/ai-chat/${pathParams?.documentId}`,
auth: () => ({ token: localStorage.getItem('token') }),
canEmit: true,
});

2. Add its type to SocketConnectionType: AiChat: 'ai_chat'.

3. Use it, usually inside async thunks:

const connectionKey = generateConnectionKey(
SocketConnectionType.AiChat,
documentId
);
await aiChatSocket.connect({ connectionKey, pathParams: { documentId } });
await aiChatSocket.on('message', connectionKey, handleMessage);
await aiChatSocket.emit(connectionKey, 'prompt', { text: 'hello' });
aiChatSocket.off('message', connectionKey, handleMessage);
await aiChatSocket.disconnect(connectionKey);

On the server side, a dynamic namespace such as /ai-chat/123 has to be allowed, for example with io.of(/^\/ai-chat\/\w+$/).

Trade-offs and what’s next

No architecture is free. A few honest limitations of the current version:

  • The auth callback is synchronous. If your token needs an async refresh, refresh it before calling connect().
  • Redux tracks one connection status. As you add sockets, you’ll want a status per connection key.
  • Error messages include the connection key, so never use sensitive values such as tokens as room ids.

All three are small, contained changes, and that’s the point: because every socket goes through one registry, improvements land in one file and every feature benefits.

Wrapping up

Real-time features rarely stay at one socket. Once you have more than one, the questions that matter are “which connection is this?” and “who owns it?”. This architecture answers both:

  • Connection keys tell you which connection is which.
  • One registry owns every connection, so it can reuse them, clean them up and keep their handlers in sync.
  • Services and thunks keep sockets out of your components.

The full code, including the chat example, is on GitHub:

If you found this useful, give it a clap, star the repo, and let me know in the comments how you manage multiple sockets in your apps.

--

--