class TypingPresence
EXPIRE_AFTER = 6 # seconds
def initialize(room, redis: Redis.current)
@room = room
@redis = redis
end
def mark_typing(user)
@redis.setex(key_for(user.id), EXPIRE_AFTER, user.display_name)
end
def clear(user)
@redis.del(key_for(user.id))
end
def typists
keys = @redis.keys("#{namespace}:*")
return [] if keys.empty?
ids = keys.map { |k| k.split(":").last.to_i }
names = @redis.mget(*keys)
ids.zip(names).filter_map do |id, name|
next if name.nil? # lapsed between keys and mget
{ id: id, name: name }
end
end
private
def namespace
"typing:room:#{@room.id}"
end
def key_for(user_id)
"#{namespace}:#{user_id}"
end
end
module RoomsChannel
class Broadcaster
def initialize(room)
@room = room
end
def typing(typists)
TypingChannel.broadcast_to(@room, payload(typists))
end
private
def payload(typists)
{
event: "typing",
room_id: @room.id,
typists: Array(typists).map { |t| { id: t[:id], name: t[:name] } },
at: Time.current.to_i
}
end
end
end
class TypingChannel < ApplicationCable::Channel
def subscribed
@room = Room.joined_by(current_user).find(params[:room_id])
stream_for @room
end
def unsubscribed
return unless @room
TypingPresence.new(@room).clear(current_user)
broadcast_roster
end
def typing(_data)
TypingPresence.new(@room).mark_typing(current_user)
broadcast_roster
end
def stopped_typing(_data)
TypingPresence.new(@room).clear(current_user)
broadcast_roster
end
private
def broadcast_roster
RoomsChannel::Broadcaster.new(@room).typing(TypingPresence.new(@room).typists)
end
end
This snippet shows how a Rails chat feature broadcasts "user is typing…" events over ActionCable while keeping short-lived typing state in Redis so it self-expires without cleanup jobs. The design separates transport (the channel) from state (the presence tracker), which keeps each piece small and testable.
In TypingChannel, the channel authorizes the connection against a Room the current user can access, then calls stream_for room so broadcasts are scoped to that room's subscribers. The typing and stopped_typing actions delegate to TypingPresence, then re-broadcast the current roster. Notice that the sender is excluded from its own indicator by comparing current_user.id on the client side rather than filtering here — the channel always sends the full set, keeping the payload idempotent and easy to reconcile.
The interesting reliability trick lives in TypingPresence. Typing is inherently ephemeral: a client that sets "typing" and then closes its laptop lid must not leave a stuck indicator forever. Rather than track explicit stop events as the source of truth, each mark_typing call writes a per-user Redis key with a short TTL (EXPIRE_AFTER) using SETEX. The presence set is reconstructed on read by scanning those keys, so a missed stopped_typing simply lapses on its own within a few seconds. mark_typing also refreshes the TTL on every keystroke burst, which is why the client only needs to ping periodically. This is the standard "heartbeat with expiry" pattern applied to presence.
typists reads the live set and resolves ids to display names, tolerating users who vanished between the keys scan and the mget. Using keys is acceptable here because the keyspace is tiny and room-scoped; a larger deployment would prefer a Redis set with per-member expiry or a sorted set keyed by timestamp.
In RoomsChannel::Broadcaster, a plain service object wraps TypingChannel.broadcast_to, giving controllers and jobs one place to emit typing events without depending on ActionCable internals. It builds a normalized payload and is easy to stub in tests. Together these files demonstrate scoping broadcasts per room, treating presence as expiring state, and decoupling emission from the channel so the real-time layer stays thin.
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.