Building a Teams-Like Chat: The Socket.IO Problems Nobody Warns You About
Dual sockets, connection leaks, cookie auth in local dev, and Nginx WebSocket upgrades - the real work behind production chat.
Building real-time chat for ABSS Teams (a Microsoft Teams–style collaboration app) sounds simple in a tutorial: io.on('connection', ...), done. In production, with real users on real networks, that's where the actual work starts.
The Problem(s)
Not one problem - a handful of them, stacked on top of each other:
1. One socket trying to do two jobs. We needed both broadcast-style events (presence, system-wide notices) and chat-room-scoped messaging. Cramming both into a single Socket.IO namespace made rooms messy and broadcasts noisy.
2. Connection leaks. Users switching tabs, losing network briefly, or closing laptops without a clean disconnect left ghost connections piling up server-side. Memory crept up over time in a way that only showed up after days of uptime - the kind of bug that's invisible in a 10-minute local test.
3. Missing room membership checks. Early on, joining a chat room was mostly trust-based on the client emitting the right room ID. That's a hole - nothing stopped a client from joining a room it had no business being in.
4. Auth via cookies breaking in local dev. Our REST APIs used cookie-based auth, which is normal and fine for HTTP. Socket.IO's handshake doesn't play with cookies the same way across all local dev setups - CORS and cookie flags (SameSite, Secure) that work fine in a same-origin production deploy fall apart across localhost:3000 talking to localhost:5000.
5. Nginx and WebSocket upgrades. In production, our reverse proxy (Nginx, serving teams.abss.ai) needed to correctly forward the WebSocket upgrade handshake. Get this wrong and Socket.IO silently falls back to long-polling - it looks like it's working, just badly, which makes it a nasty one to diagnose.
The Approach
- Split the socket concerns. We moved to a native WebSocket connection on a dedicated
/wspath for broadcast-style, presence, and system events, and kept Socket.IO focused purely on chat and reminders. This made rooms and event scoping much cleaner - each transport had one job. - Fixed leaks with explicit lifecycle handling - proper
disconnecthandlers that clean up room membership and any in-memory maps tied to that socket, plus bounding any cache that tracked active sockets so it couldn't grow unbounded even under weird disconnect patterns. - Added server-side room membership verification - before honoring a join or a message event for a room, we check the user actually belongs to that company/room in the DB, not just the client's say-so.
- Solved the cookie auth problem by moving off cookies for the socket handshake entirely. Instead of relying on the browser to attach cookies to the WebSocket upgrade request, we pass the auth token explicitly through Socket.IO's
authpayload on connection. The client fetches its token once via the normal REST flow, then hands it to the socket connection directly. This sidestepped the whole cross-origin cookie mess in local dev and made the auth path more explicit and testable everywhere. - Fixed the Nginx config to properly forward
UpgradeandConnectionheaders so the WebSocket handshake completes instead of quietly degrading to polling.
The Result
Real-time chat that's stable across day-long sessions, no memory creep from abandoned connections, no client able to snoop on a room it isn't a member of, and a dev environment where auth actually mirrors production behavior instead of working "differently but fine" locally.
The real takeaway: with WebSockets, most of the hard problems aren't in the happy path - they're in disconnects, proxies, and auth edge cases that only show up once real users and real infrastructure are involved. If your local dev auth setup looks nothing like your production auth setup, that gap will eventually bite you.