ruby 101 lines · 3 tabs

Action Cable Presence Tracking (Lightweight)

Shared by codesnips Jan 2026
3 tabs
class PresenceRegistry
  TTL = 30 # seconds a user counts as present without a heartbeat

  def initialize(room_id, redis: REDIS)
    @room_id = room_id
    @redis = redis
  end

  def touch(user_id)
    now = Time.now.to_i
    @redis.multi do |tx|
      tx.zadd(members_key, now, user_id)
      tx.setex(heartbeat_key(user_id), TTL, now)
    end
  end

  def remove(user_id)
    @redis.multi do |tx|
      tx.zrem(members_key, user_id)
      tx.del(heartbeat_key(user_id))
    end
  end

  def list
    cutoff = Time.now.to_i - TTL
    @redis.zremrangebyscore(members_key, "-inf", "(#{cutoff}")
    @redis.zrange(members_key, 0, -1)
  end

  private

  def members_key
    "presence:room:#{@room_id}:members"
  end

  def heartbeat_key(user_id)
    "presence:room:#{@room_id}:hb:#{user_id}"
  end
end
3 files · ruby Explain with highlit

Presence tracking answers a deceptively simple question: who is currently online in a given room? Doing it naively (a database column flipped on connect and off on disconnect) breaks the moment a browser tab crashes, a network drops, or a process is killed — the disconnect callback never fires and the user is stuck "online" forever. This snippet shows a lightweight approach that leans on Redis expiring keys so stale presence self-heals without any cleanup cron.

The PresenceRegistry tab wraps a small Redis-backed model. Each connected user is stored as a member of a per-room sorted set scored by the current timestamp, plus a short-lived heartbeat key with a TTL. The touch method refreshes both, so a user only counts as present if a fresh heartbeat exists. list prunes members whose score is older than TTL seconds via zremrangebyscore before returning the survivors, which means presence is eventually consistent even if a remove never runs. This is the key trade-off: instead of trusting disconnect events, the system trusts recency.

The RoomChannel tab is the Action Cable glue. On subscribed it registers the user, streams from a room-scoped broadcast, and calls broadcast_presence so everyone sees the updated roster. The heartbeat action is invoked by the client on an interval and calls registry.touch, keeping the TTL alive. unsubscribed attempts a clean remove, but that is treated as an optimization, not a guarantee — the TTL is the real backstop. Broadcasting the full member list keeps clients stateless and simple.

The Connection identification tab shows ApplicationCable::Connection resolving current_user from the signed cookie during the WebSocket handshake, rejecting unauthenticated sockets with reject_unauthorized_connection. Identifying by current_user lets channels scope presence to a real identity rather than a random socket id.

A pitfall worth noting: the heartbeat interval must be comfortably shorter than TTL, otherwise active users flicker offline between beats. Setting TTL to roughly two or three missed heartbeats gives resilience without keeping ghosts around too long. This pattern suits chat rooms, collaborative editors, and live dashboards where approximate, self-correcting presence is far more valuable than perfectly accurate but fragile bookkeeping.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<form data-controller="query-sync" data-action="change->query-sync#apply">
  <select name="status" class="rounded border p-2">
    <option value="">Any</option>
    <option value="open">Open</option>
    <option value="closed">Closed</option>
  </select>

Filter UI that syncs query params via Stimulus (no front-end router)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Action Cable Presence Tracking (Lightweight) — share card
Link copied