DEV Community

Cover image for Build Client Online Offline Status Feature using Ruby on Rails 8 Action Cable
Muhammad Syaifuddin Zuhri
Muhammad Syaifuddin Zuhri

Posted on

Build Client Online Offline Status Feature using Ruby on Rails 8 Action Cable

Ever wondered how apps like Slack, WhatsApp, or Discord know who’s online? The need for a client online status feature isn't limited to chat applications. Many real-time applications, such as banking and online exam platforms, also require this functionality.

Currently, I'm developing an online exam web application and need a client connection status feature to monitor exam participant status. This allows participants to be aware of their connection status and fix any network issues they might encounter. My solution to this challenge involves using Ruby on Rails Action Cable.

What is Action Cable?
Action Cable seamlessly integrates WebSockets with the rest of your Rails application. It enables you to write real-time features in Ruby, maintaining the same style and form as the rest of your Rails application, while still being performant and scalable. It’s a full-stack offering, providing both a client-side JavaScript framework and a server-side Ruby framework. With Action Cable, you have access to your entire domain model, whether written with Active Record or your ORM of choice.
(Source: Action Cable Documentation )

What is WebSocket?
WebSocket is a communication protocol that facilitates two-way, real-time communication between a client (in this example, a Next.js frontend) and a server over a single, persistent TCP connection. Unlike traditional HTTP requests, which are unidirectional and require a new connection for each request, WebSocket enables continuous, low-latency, bidirectional data exchange. This capability makes WebSocket an ideal protocol for real-time connection status monitoring.

In this article, I will share how I implemented Action Cable with Ruby on Rails 8, PostgreSQL, Redis, and Next.js. My aim is to demonstrate how straightforward it is to implement WebSocket functionality without relying on third-party services like Socket.io, Pusher, etc.

Ruby on Rails Action Cable is an integrated WebSocket framework. You can find more about Action Cable in its official documentation. Action Cable's main components can be separated into Server-Side Components, Client-Side Components, and Client-Server Interactions.

Action Cable Superiority:
The following table highlights the advantages of using Action Cable:

the advantages of using Action Cable

Backend Implementation
I will not explain how to start a new Ruby on Rails project, assuming you are already familiar with the process. I also assume you have configured your Rails application with a PostgreSQL database. My explanation will directly start from the Action Cable setup.

You can generate a channel from the command line using:

rails generate channel ConnectionStatusChannel

This command will generate a channels directory under your app directory, along with the necessary files for Action Cable implementation. You will find connection_status_channel.rb inside, which is the class for your channel.

Action Cable configuration is handled in the config/cable.yml file. This file is typically generated when you create your Rails application. However, you need to manually configure the following:

Configure config/application.rb add the following line to mount Action Cable at a specific path:

class Application < Rails::Application
  # ...
  config.action_cable.mount_path = '/cable'
end
Enter fullscreen mode Exit fullscreen mode

Configure config/routes.rb add this to your route list to make the Action Cable server accessible:

Rails.application.routes.draw do
  # ...
  mount ActionCable.server => '/cable'
end
Enter fullscreen mode Exit fullscreen mode

Configure Action Cable CORS. In your environment-specific configuration (e.g., development.rb, production.rb), configure CORS for Action Cable. For example, to allow all origins:

Rails.application.configure do
  # ...
  config.action_cable.allowed_request_origins = [/http:\/\/*/]
end
Enter fullscreen mode Exit fullscreen mode

Before configuring the channel, let's look at a simple user migration. Here, I've used an is_online field to store whether the user is online or not. You can adapt this method to suit your specific needs.

class CreateUsers < ActiveRecord::Migration[8.0]
  def change
    create_table :users do |t|
      t.string :username, null: false, index: { unique: true, name: "unique_username" }
      t.string :password_digest, null: false
      t.boolean :is_online, null: false, default: false

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Inside the channels directory, there is an application_cable directory containing two default files channel.rb and connection.rb. If you need to authenticate your WebSocket connection, you must modify connection.rb.

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    SECRET_KEY = Rails.application.credentials[:jwt_secret].to_s

    def decode_token(token)
      begin
        JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')
      rescue JWT::DecodeError
        nil
      end
    end

    def connect
      # Extract the token from the connection parameters
      token = request.params[:token]

      unless token
        return
      end

      begin
        # Verify the token and decode it
        decoded_token = decode_token(token)
        # Find the user based on the user_id from the decoded token
        verified_user = User.find_by(id: decoded_token[0]['uid'])
        if verified_user
          if decoded_token[0]['exp'] < Time.now.to_i
            reject_unauthorized_connection
          end
          self.current_user = verified_user
        else
          reject_unauthorized_connection
        end
      rescue JWT::DecodeError
        reject_unauthorized_connection
      end
    end

    def disconnect
      if current_user
        current_user.update(is_online: false)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Your connection_status_channel.rb file, generated by the Rails command, initially contains these three functions:

class ConnectionStatusChannel < ApplicationCable::Channel
  def subscribed
    stream_from "connection_status_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def receive(data)
    ActionCable.server.broadcast("connection_status_channel", data)
  end
end
Enter fullscreen mode Exit fullscreen mode

You need to modify it as follows:

class ConnectionStatusChannel < ApplicationCable::Channel
  def subscribed
    stream_from "connection_status_channel"
    user_online
  end

  def unsubscribed
    user_offline
  end

  def appear
    user_online
  end

  def away
    user_offline
  end

  private

  def user_online
    return unless current_user
    redis.sadd("online_participants", current_user.id)
    broadcast_online_status(current_user.id, true)
    current_user.update!(is_online: true)
  end

  def user_offline
    return unless current_user
    redis.srem("online_participants", current_user.id)
    broadcast_online_status(current_user.id, false)
    current_user.update!(is_online: false)
  end

  def broadcast_online_status(user_id, is_online)
    ActionCable.server.broadcast "connection_status_channel", { user_id: user_id, online: is_online }
  end

  def redis
    $redis
  end
end
Enter fullscreen mode Exit fullscreen mode

This modification adds the functionality to update the is_online status in your users table whenever a client subscribes to or unsubscribes from the Action Cable WebSocket.

Next.js Implementation

Here, I will explain step-by-step how to implement Action Cable in Next.js using TypeScript.

First, you need to add the necessary dependencies:

npm i actioncable @types/rails__actioncable
Enter fullscreen mode Exit fullscreen mode

Next, create a utility file to establish and manage the Action Cable connection. You can create utils/cable.ts like this:

import { createConsumer, Consumer } from '@rails/actioncable';

let cableWithToken: Consumer | null = null;
const getCableWithToken = (token: string) => {
  if (!cableWithToken) {
    cableWithToken = createConsumer(`${process.env.NEXT_PUBLIC_BASE_WEBSOCKET_URL}/cable?token=${token}`);
  }
  return cableWithToken;
}

const disconnectCableWithToken = () => {
  cableWithToken?.disconnect();
}

export { getCableWithToken, disconnectCableWithToken };
Enter fullscreen mode Exit fullscreen mode

To simplify usage across multiple React components, you can use React hooks to manage channel subscriptions. This approach creates a single WebSocket connection that can be reused by various components.

Here’s an example React hook, use-online-status.ts:

import { useState, useEffect, useRef } from 'react';
import { getCableWithToken, disconnectCableWithToken } from '@/utils/cable';
import { parseCookies } from '@/utils/cookie';
import { getUserFromJWT } from '@/utils/jwt';
import { Consumer, Subscription } from '@rails/actioncable';

const useOnlineStatus = () => {
  const [isOnline, setIsOnline] = useState<boolean>(false);
  const cableRef = useRef<Consumer | null>(null);
  const [jwtToken, setJwtToken] = useState<string | null>(null);
  const connectionStatusChannelRef = useRef<Subscription | null>(null); // Ref for the channel subscription

  useEffect(() => {
    const fetchToken = async () => {
      const cookies = parseCookies();
      const token = cookies['token'];
      setJwtToken(token);
    };
    fetchToken();
  }, []);

  useEffect(() => {
    if (!jwtToken) return; // Don't connect if there's no token!
    const user = getUserFromJWT(jwtToken); 

    cableRef.current = getCableWithToken(jwtToken); // Initialize Action Cable with token

    let isMounted = true;

    connectionStatusChannelRef.current = cableRef.current.subscriptions.create(
      {
        channel: 'ConnectionStatusChannel',
      },
      // @ts-expect-error: Action Cable handler mixin does not match Subscription type, but this is safe.
      {
        connected() {
          if (!isMounted) return;
          this.appear?.();
          setIsOnline(true); // Set online status on connect
        },
        disconnected() {
          if (!isMounted) return;
          this.disappear?.();
          setIsOnline(false); // Set offline status on disconnect
        },
        appear() {
          if (!isMounted) return;
          this.perform?.('appear');
          setIsOnline(true);
        },
        disappear() {
          if (!isMounted) return;
          this.perform?.('disappear');
          setIsOnline(false);
        },
        received(data: { user_id: number; online: boolean; is_force?: boolean }) {
          // Use the 'received' callback
          if (isMounted && data.user_id === user.id) {
            setIsOnline(data.online);
          }
        }
      }
    );

    return () => {
      isMounted = false; 
      if (connectionStatusChannelRef.current) {
        connectionStatusChannelRef.current.unsubscribe(); // Unsubscribe using the ref
        connectionStatusChannelRef.current = null;
      }
      disconnectCableWithToken(); // Disconnect when the component unmounts
    };
  }, [jwtToken]); // Dependency on jwtToken

  return isOnline;
};

export default useOnlineStatus;
Enter fullscreen mode Exit fullscreen mode

With this hook, you can implement it in your component like this:

"use client"

import useOnlineStatus from "@/hooks/use-online-status";
import { cn } from "@/lib/utils";

export default function ConnectionStatusBadge() {
  const isOnline = useOnlineStatus();

  return (
    <div className="flex items-center gap-1 ml-auto">
      <div
        className={cn("w-2 h-2 rounded-full", {
          "bg-green-500": isOnline,
          "bg-red-500": !isOnline,
        })}
      ></div>
      <p className="text-sm text-muted-foreground">
        {isOnline ? "Online" : "Offline"}
      </p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This will display the connection status on your client page.

Challenges and Considerations

Building a real-time presence system, while powerful, comes with its own set of challenges and considerations. During my implementation, I encountered a few key areas that are worth noting for anyone embarking on a similar project.

A. Real-time Latency and Action Cable's Ping Interval

One of the first things I observed was a slight delay in the online/offline status updates, typically around 5 seconds or more. This delay is directly caused by Action Cable's default ping interval. Action Cable sends a "ping" frame every few seconds to keep the WebSocket connection alive and detect dead connections. The client's "offline" status is often registered only after a few missed pings, leading to this inherent delay.

While this means the status might not be instantaneously real-time in the millisecond sense, for many practical applications like general user presence, this small delay is perfectly acceptable and sufficient to solve the core problem of knowing who's generally available. It's an important trade-off to be aware of when relying on Action Cable's built-in connection management.

B. Robust Offline Detection (Beyond unsubscribed)

While the unsubscribed callback in Action Cable is crucial for marking a user offline when they gracefully close their browser tab or navigate away, it's not foolproof. What happens if a user's browser crashes, their internet connection suddenly drops, or their computer goes to sleep? In such scenarios, the unsubscribed callback might not fire reliably.

To ensure more robust offline detection, it's highly recommended to implement a "heartbeat" or last_seen_at timestamp mechanism. This involves:

Updating a timestamp: Whenever a user connects to Action Cable or sends any message through the WebSocket, update a last_seen_at column on their User record in your PostgreSQL database.
Defining "offline": On the frontend, or via a background job on the backend, a user can be considered offline if their last_seen_at timestamp is older than a certain threshold (e.g., 30 seconds, 1 minute, 5 minutes). This provides a more resilient way to determine true unavailability, even if a clean disconnection doesn't occur.

C. Authentication and Authorization

Securing your WebSockets is paramount. Just like HTTP requests, WebSocket connections need proper authentication to ensure that only legitimate users can connect and that their identity is correctly established. Action Cable's connection.rb file is where you handle this, typically by verifying session cookies or authentication tokens sent during the initial WebSocket handshake. Beyond authentication, consider authorization – ensuring users only receive presence updates for other users they are permitted to see (e.g., friends in a social network, team members in a work app).

D. Scalability Considerations

For small to medium-sized applications, Action Cable performs admirably. However, as your user base grows significantly (tens of thousands or hundreds of thousands of concurrent connections), you might start hitting limitations. Action Cable typically relies on a single Redis instance as its pub/sub backend, which can become a bottleneck. For very large-scale, high-concurrency presence systems, you might need to explore:

Distributed Redis setups: More robust Redis configurations.
External WebSocket services: Solutions like Pusher, Ably, or Google Cloud Pub/Sub with dedicated WebSocket gateways, which are built to handle massive numbers of concurrent connections and global distribution.

E. Handling Multiple Tabs or Devices

A single user might have your application open in multiple browser tabs or across different devices (e.g., desktop and mobile). How should your system interpret this?

"Anywhere Online": If the user is online in any tab or device, they are considered online. This is often the desired behavior for general presence. Your current PresenceChannel approach, where subscribed marks them online and unsubscribed only marks them offline when all connections for that user are gone, naturally handles this.

"Per-Device Status": If you need to know which specific device or tab a user is online from (e.g., "John is online on his phone"), you would need to augment your presence logic to store and broadcast connection-specific IDs or metadata.
By addressing these considerations, you can build a more robust, secure, and scalable real-time presence system with Ruby on Rails Action Cable and Next.js.

Conclusion

In this article, we've explored the practical implementation of a real-time client online/offline status feature using Ruby on Rails Action Cable and a Next.js frontend. We delved into the core concepts of WebSockets and Action Cable, understanding how they enable seamless, bidirectional communication. By walking through the backend setup in Rails, including connection authentication and channel management, and the frontend integration with Next.js hooks, we've demonstrated a complete solution for real-time presence.

While acknowledging the inherent latency due to Action Cable's ping interval, we discussed strategies for robust offline detection, emphasizing the importance of last_seen_at timestamps for reliability. We also touched upon critical considerations such as authentication, scalability, and handling multiple user connections.

This implementation provides a solid foundation for adding real-time user presence to your applications, enhancing user experience and enabling dynamic features. Action Cable, with its deep integration into the Rails ecosystem, truly simplifies the development of such complex functionalities without needing external WebSocket services. I encourage you to experiment with this approach and adapt it to your own application's needs.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.