Recently, I wanted the ability to check if a user was active on my site. Having never done this before in rails, I Googled around for inspiration, but what I found was pretty disappointing. Most of the blog posts and answers on Stack Overflow were just variations of the same solution:

Add a last_seen column to the users table. Update the column with a current timestamp every time a user loads a new page. Check if last_seen was updated in the last fifteen minutes to determine who’s online.

Boo. Trading a bazillion extra trips to the database, for a 15 minute window where we can maybe assume a user is still active feels like a bad solution. It’s not just inefficient; it’s unreliable.

We can do a lot better by using websocket connections instead of http requests and redis instead of an on-disk database. Granted, this could be overkill in a lot of cases, but if you’re already using websockets and/or redis in other ways, it fits in pretty naturally.

TL;DR

The real secret sauce here is websockets, specifically that the server can tell when a websocket connection is closed. Redis just provides a nice little performance boost as it stores everything in memory instead of on disk.

The idea is to open a private user channel on every open page/tab/device. Every time a channel opens we increment a counter for the user opening it in our redis store and when a channel closes we decrement the same counter. We can then check redis to see if a user’s connection count is > 0 to determine if they’re online. We can even broadcast updates (via websockets) when a user arrives or leaves in order to update a user’s online status in real time.

This way we get reliability up to the millisecond without any expensive writes to disk. It’s even more efficient if you’re using something like turbolinks or PJAX and can get away with only opening one connection across multiple pages.

Coding It Up

Here’s what implementing this might look like in Rails 4 and jQuery. Although, it shouldn’t be too hard to port the core concepts over to some other language or framework.

The first thing we need to do is get all of our dependencies installed. Make sure you have redis up and running on your machine. We’ll also be using websocket-rails, redis-rb and devise. (Although devise isn’t strictly necessary, it saves us from writing a lot of user authentication boilerplate.)

First let’s add the gems we need to our Gemfile:

gem 'devise' gem 'redis' gem 'websocket-rails'

Run bundle install and then follow the devise guide to create an "authenticatable" user model.

Next, let’s setup our redis initializer: 1

# congif/initializers/redis.rb $redis = Redis . new ( :host => 'localhost' , :port => 6379 , :driver => :hiredis )

Then we’ll run the generator to setup the websocket-rails gem:

rails g websocket_rails:install

Now let’s create a unique key to use as the name for a user's private channel. You'd probably ok just using a user’s id as the channel name but I always feel safer exposing something non-sequential.

# create a new migration class AddChannelKeyToUsers < ActiveRecord :: Migration def change add_column :users , :channel_key , :string end end

# app/models/user.rb . . . before_create :generate_channel_key private def generate_channel_key begin key = SecureRandom . urlsafe_base64 end while User . where ( :channel_key => key ) . exists? self . channel_key = key end . . .

Make sure to run rake db:migrate after you create your migration. While we’re in the user model, let’s add a "concern" to handle checking and setting who’s online.

# app/models/concerns/users/online_status.rb module Concerns module Users module OnlineStatus extend ActiveSupport :: Concern HASH_KEY = 'online_users' included do scope :online , find_all_by_id ( $redis . hgetall ( HASH_KEY ) . keys ) end def online? $redis . hget ( HASH_KEY , self . id ) . to_i > 0 end def seen $redis . hincrby ( HASH_KEY , self . id , 1 ) end def left user_connections = $redis . hincrby ( HASH_KEY , self . id , - 1 ) $redis . hdel ( HASH_KEY , self . id ) if user_connections <= 0 user_connections end module ClassMethods def online_count $redis . hlen HASH_KEY end end end end end

# app/models/user.rb class User < ActiveRecord :: Base include Concerns :: Users :: OnlineStatus . . .

After that let’s create a websocket controller to handle authorizing the user channel connections, updating redis with the connection count, and pushing updates to the client when a user arrives or leaves:

# app/controllers/web_sockets/authorization_controller.rb class WebSockets :: AuthorizationController < WebsocketRails :: BaseController def get_channel_key if user_signed_in? key = current_user . channel_key WebsocketRails [ key ]. make_private send_message :key , key , :namespace => :user else send_message :key , nil , :namespace => :user end end def authorize_user_channel if user_signed_in? and current_user . channel_key == message [ :channel ] if current_user . seen == 1 WebsocketRails [ :online_users ]. trigger "seen" , current_user end accept_channel nil else deny_channel nil end end def client_disconnected if current_user . left <= 0 WebsocketRails [ :online_users ]. trigger "left" , current_user end end end

Next we subscribe our client side events to their appropriate controller actions:

# config/events.rb WebsocketRails :: EventMap . describe do subscribe :client_disconnected , 'web_sockets/authorization#client_disconnected' namespace :user do subscribe :get_channel_key , 'web_sockets/authorization#get_channel_key' end namespace :websocket_rails do subscribe :subscribe_private , 'web_sockets/authorization#authorize_user_channel' end end

Finally we'll write few little javascript classes2 to initialize the user channel connection and monitor when users arrive and leave. Note we're using jQuery along with the jQuery cookie plugin.

var UserChannel = ( function () { var dispatcher , channelName , cookieName = 'user_channel_key' ; function init ( globalDispatcher ) { dispatcher = globalDispatcher ; dispatcher . on_open = connect ; channelName = $ . cookie ( cookieName ) } function connect () { if ( channelName ) { getChannel ( channelName ); } else { getKey (); } } function getKey () { dispatcher . bind ( 'user.key' , function ( key ) { $ . cookie ( cookieName , key , { expires : 30 }); getChannel ( key ); }); dispatcher . trigger ( 'user.get_channel_key' , {}); } function getChannel ( key ) { var channel = dispatcher . subscribe_private ( key ); channel . on_success = function () { // listen for notifications or some other user specific event }; channel . on_failure = function ( reason ) { $ . removeCookie ( cookieName ); console . log ( "Authorization failed because " + reason . message ); }; } return { init : init }; })();

var UserMonitor = ( function () { var dispatcher , channel , onlineUsers , $userCountContainer ; function init ( globalDispatcher ) { dispatcher = globalDispatcher ; channel = dispatcher . subscribe ( 'online_users' ); channel . bind ( 'seen' , userOnline ); channel . bind ( 'left' , userOffline ); setOnlineUserCount (); } function setOnlineUserCount () { $userCountContainer = $ ( '#online-user-count' ); if ( $userCountContainer . length ) { onlineUsers = parseInt ( $userCountContainer . html (), 10 ); } } function userOnline ( user ) { var $onlineUser = $ ( '.user-online[data-user-id="' + user . id + '"]' ); $onlineUser . addClass ( 'online' ); $onlineUser . removeClass ( 'offline' ); $onlineUser . html ( 'online' ); if ( $userCountContainer . length ) { updateOnlineUserCount ( ++ onlineUsers ); } } function userOffline ( user ) { var $offlineUser = $ ( '.user-online[data-user-id="' + user . id + '"]' ); $offlineUser . removeClass ( 'online' ); $offlineUser . addClass ( 'offline' ); $offlineUser . html ( 'offline' ); if ( $userCountContainer . length ) { updateOnlineUserCount ( -- onlineUsers ); } } function updateOnlineUserCount ( count ) { $userCountContainer . html ( count ); } return { init : init }; }());

$ ( document ). on ( 'ready' , function () { var globalDispatcher = new WebSocketRails ( 'http://localhost:3000/websocket' ); UserChannel . init ( globalDispatcher ); UserMonitor . init ( globalDispatcher ); });

I also chose to set the channel key cookie in rails after a user logs in to keep from having to call getKey() in most cases.

# app/controllers/application_controller.rb . . . USER_CHANNEL_KEY = 'user_channel_key' def after_sign_in_path_for ( resource ) if resource . is_a? User set_user_channel_cookie end super end def after_sign_out_path_for ( resource ) if resource . is_a? User clear_user_channel_cookie end super end private def set_user_channel_cookie key = current_user . channel_key WebsocketRails [ key ]. make_private cookies [ USER_CHANNEL_KEY ] = { :value => key , :expires => 30 . days . from_now } end def clear_user_channel_cookie cookies . delete USER_CHANNEL_KEY end . . .

And with that, we’re all done. Now we're able to wire up an online users page like so:

# app/controllers/user_controller.rb . . . def online @online_users = User . online @online_user_count = User . online_count end . . .

<%# app/views/user/online.html.erb %> <% @online_users . each do | user | %> <div> <%= user . email %> - <span class="online-user" data-user-id=" <%= user . id %> "> <%= user . online? ? 'online' : 'offline' %> </span> </div> <% end %> <div> <span id='online-user-count'> <%= @online_user_count %> </span> users currently online </div>





Wrapping Up

While this might seem like a lot of work for such a small feature we can reuse the user channel we created to push other user related events like notifications or messages in real time.

If you’ve been paying close attention you’ll notice that there’s still one flaw with our setup: if a user opens a tab and then walks away from their computer, three hours later they’ll still be considered “online.” This shouldn't be too difficult to solve, but how you choose to handle this really depends on your app. One reasonable solution would be to just close any connection that’s older than 5-10 minutes on the server and update redis and the client accordingly. However, if you're writing a one page app or using turbolinks/pjax you might want to consider handling these sorts of timeouts client-side.

I think that just about covers it. Let me know what you think, questions, comments and suggestions are more than welcome.

1 Notice we’re using ‘hredis’ as our driver. “Websocket-rails” requires “thin” as a webserver which seems to cause conflicts with redis’ default driver, ‘synchrony.’ Note that hredis won’t work with JRuby.