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.

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:
- One row per subscription, owned by a leased process incarnation. A killed Puma worker’s users disappear within about 10 s when Puma respawns it, and within about 19 s at worst. A process that cannot prove its lease closes its own connections first, so a live user is never hidden.
- Half-open detection. The gem ships a backport of rails/rails#58798 (client PONGs), server side and JS side. A silent client is dropped after about 9 s.
- A remove beats a stale add. Action Cable runs a connection’s messages concurrently on a thread
pool, so an
unsubscribecan execute before its ownsubscribe. The gem handles that ordering. - Clients reconcile. Diffs are versioned. Clients buffer out-of-order diffs, resync on a gap, and keep showing members across a reconnect until the fresh snapshot arrives.
- Phoenix’s shape. A key maps to per-connection metas, so two tabs of one user are one member with two metas.
- AnyCable’s API and wire format.
@anycable/web’schannel.presenceworks unchanged, and a channel can move to AnyCable later.
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
test/: example tests for each rule against real SQLite. They also run on PostgreSQL withPRESENCE_TEST_DB=postgresql. There are generator tests, and a guard that every simulator mutant still applies.sim/: a deterministic simulator that drives the real gem code (Tracker, SqlStore on a real SQLite database, the client reconciler) through three simulated Puma workers for one virtual hour per seed. It injects seven fault types: SIGKILL, freezes, graceful and in-process restarts that discard callbacks, database and pubsub partitions, fsync stalls, late or lost commit replies, pubsub drop/duplicate/reorder, clock skew, 1,000-client reconnect storms, and subscribe/unsubscribe races. Invariants: no permanent ghosts, bounded ghosts, zero false-absent seconds, rows of closed connections removed, client views converge, and no spurious fences. Seesim/README.md.sim/mutants.rb: 32 plausible bugs. The mutation score is how the oracle proves it can catch them.load/: real-process checks againstdemo/(Puma, 2 workers, Solid Cable):kill_test.rbrunskill -950 times with 500 clients, on SQLite or PostgreSQL.pong_check.rbtests the #58798 backport.anycable_check.rbruns the real@anycable/webclient in Node.storm.rbmeasures a 1,000-client reconnect storm.pg_check.rbruns concurrent transactions on PostgreSQL.demo_page_check.mjsdrives three headless Chromium visitors through the live demo: rename, kill the serving worker, the tutorial-pattern ghost, and a simulated dead network.hammer.mjsfires bursts of kill clicks at the demo and checks the rate limits and/up.
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).
- Kill requests are rate limited: one per room every 30 s, 10 per IP per hour, 6 per minute for the whole site. The HTTP request only issues a signed, single-use ticket. The visitor’s own WebSocket redeems it, so the worker that holds the socket is the one that kills itself. The client never names a pid.
- Visitors get a random name and colour in a signed cookie.
?user=works only in development, in test and underPRESENCE_LOAD_TEST=1; the app refuses to boot with that flag set on the public host. - Rooms are capped at 500. Idle rooms nobody is in are pruned after an hour, and so are the tutorial pattern’s ghost rows.
| Desktop | Mobile |
|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
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




