Revisiting the single-user multi-connect problem

In an earlier post, I wrote about a problem I had encountered when using WebSockets (via socket.io) to track users viewing a page. The logic is simple:

  1. A user hits a URL
  2. Part of the URL is used to create a new socket.io room
  3. The socket joins this room and the user is added to a Redis set for that room (user list)

The problem occurs when we need to update the user list upon user disconnect. If one user has the same URL open in more than one tab/window, then the disconnect handler should only remove the user from the list if all of its connections are disconnected. If we don't have any checks in place for this, then the user is removed from the list even though they are still connected in another tab/window.

My previous solution was to create a separate socket.io room for all users and intersect the user clients with the page clients to determine if a user has more than one client open on a particular page. So this worked well enough, but socket.io 1.0 no longer provides the io.sockets.clients function to get a list of clients per room. To be fair, the issue is being addressed: https://github.com/Automattic/socket.io/pull/1630

However, I needed a new solution. So I'm back to Redis after all. This time my solution is a bit more intuitive and puts less strain on the Node process (no client lookups for every disconnect event). Here is the gist of it:

io.on('connection', function(socket) {  
    socket.on('subscribe', co(function *(msg) {

        // Socket joins room for the page
        socket.join(msg.channel);

        // Assign user and page to socket 
        // so we can perform a lookup on disconnect
        socket.channel = msg.channel;
        socket.user = msg.user;

        // Update user list for page
        yield store.sadd(msg.channel, msg.user);

        // Create hash for user + page
        var hash = msg.channel+':'+msg.user;
        yield store.hsetnx(hash, 'count', 0 );

        // Increment count by 1
        yield store.hincrby(hash, 'count', 1);

        // Get user list and broadcast to room
        var users = yield store.smembers(msg.channel);
        io.to(msg.channel).emit('message', users);
    }));

    socket.on('disconnect', co(function *() {

        // Get hash that we created on connect
        var hash = socket.channel+':'+socket.user;

        // Decrement count by 1
        yield store.hincrby(hash, 'count', -1);
        var count = yield store.hget(hash, 'count');

        if (parseInt(count) === 0) {
            yield store.srem(socket.channel, socket.user);
            yield store.hdel(hash, 'count');
        }

        var users = yield store.smembers(socket.channel);
        io.to(socket.channel).emit('message', users);
    }));
});

Instead of joining a new room for each user, I am creating a hash in Redis and incrementing a count field for each connection from the particular user and channel (page/URL) they are on. This assumes that users are using unique identifiers. When they disconnect, I decrement the counter, and if the counter reaches 0 then I remove them from the user list for that page.

The downsides here are extra calls to Redis and having to clean up all the extra hashes, but otherwise it works the same as my previous approach. The benefits are less CPU and memory usage from Node. The hash operations in Redis are extremely trivial with regards to server resources.