ruby 90 lines · 3 tabs

Broadcast Typing Indicators in Rails Chat with ActionCable and a Redis Presence Tracker

Shared by codesnips Sep 2026
3 tabs
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
3 files · ruby Explain with highlit

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

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.

Broadcast Typing Indicators in Rails Chat with ActionCable and a Redis Presence Tracker — share card
Link copied