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
class RoomChannel < ApplicationCable::Channel
def subscribed
@room_id = params[:room_id]
reject and return if @room_id.blank?
stream_from broadcast_key
registry.touch(current_user.id)
broadcast_presence
end
def heartbeat(_data)
registry.touch(current_user.id)
end
def unsubscribed
return if @room_id.blank?
# Best-effort cleanup; the Redis TTL is the real safety net.
registry.remove(current_user.id)
broadcast_presence
end
private
def registry
@registry ||= PresenceRegistry.new(@room_id)
end
def broadcast_key
"room:#{@room_id}:presence"
end
def broadcast_presence
ActionCable.server.broadcast(
broadcast_key,
type: "presence",
online: registry.list.map(&:to_i)
)
end
end
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
user_id = cookies.signed[:user_id]
user = User.find_by(id: user_id) if user_id
if user
user
else
reject_unauthorized_connection
end
end
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<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)
Share this code
Here's the card — post it anywhere.