actioncable-presence

The repository is private until the 0.1.0 release, so its README is rendered here. Back to the demo.

actioncable-presence

Who is here, for Action Cable, with a hard bound on how wrong it can be.

Live demo: presence.latebuild.com. Open a room, invite someone, then press “Kill the worker serving you”: the Puma worker holding your WebSocket gets kill -9, and your ghost clears in about 10 s. Next to it, the Action Cable guide’s pattern keeps the ghost forever.

A room after a worker was killed: the gem's list has already dropped the ghost, the tutorial pattern still shows it

class RoomChannel < ApplicationCable::Channel
  include ActionCable::Presence::Channel

  def subscribed
    stream_from "room:#{params[:id]}"
    join_presence(id: current_user.id, info: { name: current_user.name })
  end
end
const { count, list } = usePresence(consumer, { channel: "RoomChannel", id: room.id })

Presence is the classic “looks easy” feature. The Action Cable guide’s pattern adds a user on subscribed and removes them on unsubscribed. That pattern leaves permanent ghosts whenever a process is killed, because no callbacks run. It also leaves them when a laptop lid closes, because the socket stays half-open for many minutes, and when a restart discards queued callbacks. It hides a user who still has another tab open. This gem is built around those failures:

It runs on Rails’ default stack: Solid Cable on SQLite, one box. It also runs on PostgreSQL with several servers.

Quick start (Rails 8, Solid Cable on SQLite)

bundle add actioncable-presence
bin/rails generate action_cable:presence Room   # migration in db/cable_migrate, initializer, channel
bin/rails db:migrate
npm install @action-cable-presence/client       # or copy js/src (plain TS, no dependencies)

The generator adds include ActionCable::Presence::Connection to ApplicationCable::Connection, which hooks connection close. Then, where you create your consumer:

import { createConsumer } from "@rails/actioncable"
import { installPongs } from "@action-cable-presence/client"

export const consumer = createConsumer()
installPongs(consumer) // the server can now tell a dead socket from a quiet one

React (Inertia or not):

import { usePresence } from "@action-cable-presence/client/react"

function Here({ roomId }: { roomId: number }) {
  const { count, list } = usePresence(consumer, { channel: "RoomChannel", id: roomId })
  return <p>{count} here: {list((key, metas) => metas[0].name).join(", ")}</p>
}

Without React, PresenceState is the same reconciler. The demo (demo/app/javascript/room.js) uses it with an import map and no bundler. examples/inertia/ has an Inertia page with shadcn/ui avatars.

Channel API

Ruby  
join_presence(topic = first stream, id:, info: {}) Join. The subscription confirmation is deferred until the row is durable.
leave_presence Leave, but stay subscribed (for example, the tab is hidden).
watch_presence(topic = first stream) Receive presence without joining (a read-only viewer).
accept_client_presence?(id, info) Override to allow AnyCable clients’ channel.presence.join(id). Off by default, because the client picks the id.

Our clients receive {presence: {type: "state" \| "diff" \| "digest", v, ...}} on the channel and perform presence_state to resync. PresenceState / usePresence handle this for you. AnyCable clients (subprotocol actioncable-v1-ext-json) get AnyCable 1.6 presence / join / leave semantics instead.

Configuration

# config/initializers/action_cable_presence.rb
Rails.application.config.action_cable_presence.database = :cable # Solid Cable's database
Rails.application.config.action_cable_presence.pong = true       # rails#58798 backport
Rails.application.config.action_cable_presence.anycable = true   # accept actioncable-v1-ext-json
ActionCable::Presence.config = ActionCable::Presence::Config.default(lease: 15.0, renew_every: 3.0)

Staleness bounds with the defaults:

Failure Stale for at most
Tab closes cleanly one flush (about 20 ms) plus pubsub latency (Solid Cable polls every 100 ms)
Half-open client (lid closed, network gone), PONG on about 9 s
Half-open client, PONG off (Action Cable 8.1 today) until TCP gives up, often many minutes
Puma worker SIGKILLed and respawned about 10 s (the new incarnation retires the old one)
Worker killed and not respawned about 19 s (renew + lease + sweep)
Owner dies inside a half-open client’s PONG window about 29 s (compound failure)

Postgres

Works the same way. apply takes FOR SHARE on its process row, so a sweep cannot delete an incarnation while one of its joins commits. Solid Cable on PostgreSQL can skip a message whose id commits out of order, which load/pg_check.rb reproduces. Presence does not depend on every diff arriving: once a second, each process compares database versions with what pubsub delivered and sends a digest, and clients resync from a snapshot. MySQL is not supported yet.

How it is tested

The demo

demo/ is the app behind presence.latebuild.com: Rails 8.1, Solid Cable and Solid Cache on SQLite, Puma with 3 workers, import maps, no Node. Kamal deploys it (cd demo && kamal deploy; see demo/config/deploy.yml).

Desktop Mobile
Landing page Landing page, dark, 390 px
Three people in a room Three people in a room, 390 px
Tutorial pattern keeps the ghost Tutorial pattern keeps the ghost, 390 px

Measured on 0.1.0 (2026-09-29, a shared 16-core machine under load):

Check Result
Simulator, 1,000 seeds × 1 virtual hour, all 7 fault types 0 permanent ghosts, 0 s of live users shown absent. Longest ghost 28.45 s, all past 21 s from compound failures (owner died during a half-open client’s PONG window), inside the 29.24 s analytic bound.
Mutation testing, 32 seeded bugs × 20 seeds 32/32 caught
Tutorial pattern and Campfire pattern in the same simulator Both fail 20/20 (tutorial: up to 3,450 s ghosts)
Real Puma, 2 workers, 500 clients, 50 × kill -9 SQLite: ghosts 10.0–11.0 s, 0 leftover rows. PostgreSQL: 9.1–13.7 s, 0 leftover. One earlier SQLite run under heavy machine load had a 65 s outlier that is not yet explained.

Status: version 0.1.0, not released.

License

MIT