DEV Community

The Bug That Taught Me What "Real-Time" Actually Means

When I first deployed ChatSphere, it looked done. Two tabs, two users, messages flying back and forth instantly. I recorded a demo, felt good about it, moved on. Then I opened a third tab.

The Bug

I logged in as the same user in two different tabs - one on my laptop, one on my phone, both signed in as renuka. I closed the laptop tab. The phone tab, still open and still connected, suddenly showed me as offline in the sidebar. I was still there. I just wasn't showing as online anymore.

The root cause was embarrassingly simple once I found it: my online-user tracking was keyed by socket ID, not by username.

// The naive version - one socket = one entry
const onlineUsers = new Map(); // socket.id -> username

socket.on('disconnect', () => {
  onlineUsers.delete(socket.id);
  broadcastOnlineUsers();
});

Every browser tab opens its own socket connection. So "one user, two tabs" was really "two sockets, no relationship between them" as far as my server was concerned. Close one tab, and the server had no way to know the user was still connected somewhere else - it only knew that socket had disconnected.

Why This Matters More Than It Looks

This isn't just a cosmetic bug. In a real chat product, incorrect presence data breaks trust - imagine Slack showing your teammate as offline while they're actively typing to you. It also hints at a bigger category of problems: anything keyed by connection instead of identity breaks the moment a user has more than one connection, which is the normal case, not the edge case, once you have both a phone and a laptop.

The Fix

The fix was to track presence per user, with each user mapped to a set of active socket connections:

const onlineUsers = new Map(); // username -> Set of socket ids

io.on('connection', (socket) => {
  const { username } = socket;

  if (!onlineUsers.has(username)) {
    onlineUsers.set(username, new Set());
    socket.broadcast.emit('userJoined', { username });
  }

  onlineUsers.get(username).add(socket.id);
  broadcastOnlineUsers();

  socket.on('disconnect', () => {
    const sockets = onlineUsers.get(username);
    if (sockets) {
      sockets.delete(socket.id);

      // Only mark the user offline once ALL their connections are gone
      if (sockets.size === 0) {
        onlineUsers.delete(username);
        socket.broadcast.emit('userLeft', { username });
      }
    }
  });
});

Now a user only disappears from the online list when their last connection drops, not their first. Multi-tab, multi-device - both work correctly.

The Second Problem This Exposed: Nothing Stopped Spam

While fixing this, I noticed a related gap: nothing was stopping a single connected client from firing off hundreds of messages a second, either by accident (a stuck key, a buggy client) or on purpose. There was no cost to sending - just an open pipe straight to the database.

I added a simple sliding-window rate limiter, scoped per socket connection:

const messageWindows = new Map(); // socket.id -> array of timestamps

function isRateLimited(socketId, maxMessages = 5, windowMs = 10000) {
  const now = Date.now();
  const timestamps = (messageWindows.get(socketId) || [])
    .filter(t => now - t < windowMs);

  if (timestamps.length >= maxMessages) {
    messageWindows.set(socketId, timestamps);
    return true;
  }

  timestamps.push(now);
  messageWindows.set(socketId, timestamps);
  return false;
}

No Redis, no extra dependency - just an in-memory sliding window, cleaned up on disconnect. It's not the solution I'd reach for at scale across multiple server instances (a distributed cache would be needed there, since in-memory state doesn't share across processes), but for a single-instance deployment it's the right amount of engineering for the actual problem.

What I'd Do Differently at Real Scale

If this needed to survive multiple server instances behind a load balancer, in-memory Maps stop working - two users on two different server instances wouldn't see each other's presence or rate-limit state. That's the point where I'd reach for Redis: a shared SET per username for presence, and Redis's INCR + EXPIRE for rate limiting, so all server instances read from the same source of truth.

I didn't build that here, because it would have been solving a problem I don't have yet. But knowing where the current design breaks - and why - feels like the more useful skill than defaulting to the "enterprise" solution everywhere out of habit.

The Actual Lesson

The bug wasn't really about Socket.io or Maps. It was a reminder that "it works when I test it" and "it works" are different claims, and the gap between them is usually hiding in the case you didn't think to test. Two tabs, same user, is not an exotic edge case - it's Tuesday. Production thinking means testing for Tuesday, not just the happy path.

Project: ChatSphere - Real-Time Chat App ยท Live demo

Comments

No comments yet. Start the discussion.