About rails-hotwire-realtime
rails-hotwire-realtime is an architecture and review guide for Rails developers using Turbo, Stimulus, and ActionCable. It explains when to use Turbo Streams versus lightweight ActionCable signals, alongside patterns for realtime updates, connection safety, and testing.
Key features
- 01Choose streams-only or cable-augmented architecture
- 02Update pages with Turbo Streams
- 03Scope broadcasts by user and tenant
- 04Catch up after reconnects
- 05Clean up and coordinate Stimulus controllers
- 06Patterns for realtime feature tests
Use cases
- 01Choosing realtime updates for CRUD applications
- 02Updating multiple page regions atomically
- 03Adding presence and typing signals to chat
- 04Testing multi-user realtime broadcasts
Install
Copy the command and run it in a terminal.
$
npx skills add https:/ / github.com/ marckohlbrugge/ 37signals-skills --skill rails-hotwire-realtimeAfter installing, type/rails-hotwire-realtimein Claude Code to confirm it loaded.
Details
From the project READMERails Hotwire + Realtime
Use for Turbo/Stimulus/ActionCable architecture and reviews. Patterns from Campfire (cable-heavy chat) and Fizzy (streams-only kanban).
Choose the Right Realtime Architecture
Two proven 37signals topologies:
- Streams-only (Fizzy): no custom ActionCable channels at all.
turbo_stream_from,broadcasts_refreshes+ morph, lazy frames with ETags. Default to this for CRUD-ish apps. - Cable-augmented (Campfire): custom channels only for lightweight JSON signals (presence, typing, unread pings) where rendering HTML server-side would be wasteful. Durable DOM state still goes through Turbo Streams.
Rule of thumb: Turbo Streams for anything that changes the DOM; bare ActionCable only for tiny ephemeral signals.
Turbo Defaults
- Prefer server-rendered partial updates over heavy client-side state systems.
- Set global morph refresh in the layout:
turbo_refreshes_with method: :morph, scroll: :preserve. - Use Turbo Streams for targeted updates; one action can render a multi-target stream template that updates every affected region atomically (source column + destination + detail pane).
- Lazy-load expensive sections with
turbo_frame_tag ..., src:, loading: :lazyand give the frame endpoint its ownfresh_whenETag. Extract frequently-changing fragments into their own frames so they don't bust the parent cache. - Use
data-turbo-permanentfor elements that must survive navigation/morph (footer trays, in-progress editors). - Block morph from clobbering client-owned state (e.g. localStorage-driven collapsed columns) via
turbo:before-morph-attribute+preventDefault(). - Exempt realtime-heavy pages from Turbo's page cache (
turbo_exempts_page_from_cache); rely on frame-level ETags instead. - Optimistic UI without a JS framework: server-render a
<template>partial with$placeholder$tokens; client clones it with a generated ID before submit; the stream response replaces it.
Broadcast Patterns
- Keep broadcast logic on models (
Message::Broadcastsconcern withbroadcast_create), not in controllers. - Scope every stream name by tenant/user:
[board.account, :all_boards],[user, :notifications]— stream names are isolation boundaries. - Dual streams when needed:
broadcasts_refreshesfor direct subscribers plusbroadcasts_refreshes_to ->(r) { [r.account, :aggregate_view] }for account-wide views. - Gate noisy secondary broadcasts on meaningful change: set a flag in
before_updatewhen preview-relevant fields change; broadcastif: :preview_changed?. - Suppress broadcasts in background jobs that incidentally touch records (
Model.suppressing_turbo_broadcasts), e.g. around ActiveStorage analysis. - Fan-out efficiently:
render_to_stringonce, thenbroadcast_replace_to user, ..., html:per recipient. - Use
broadcast_*_laterasync variants when synchronous broadcasting hurts request latency. - Broadcast-rendered partials lack request context: wrap attachment/url helpers (
broadcast_image_tagpattern) so URLs resolve.
Reconnect & Catch-Up
- Catch-up over reload: on reconnect/tab-return, fetch a turbo-stream diff (
?since=<epoch_ms>); server appends new records and replaces updated ones. - An empty
HeartbeatChannelgives reliableconnected/disconnectedcallbacks for triggering catch-up and a debounced offline UI. - Guard Stimulus
connect()with a turbo-preview check so back/forward cache previews don't open sockets or request permissions.
Stimulus Defaults
- Keep controllers single-purpose; compose multiple small controllers on one element.
- Prefer targets/values over ad-hoc selectors and attribute parsing.
- Always clean up timers/listeners/subscriptions in
disconnect. For cable subscriptions, defer unsubscribe one animation frame and skip if the element reconnected (morph churn). - Use event dispatch between controllers (or outlets) instead of tight coupling.
- For complex surfaces, compose
data-controller/data-action/target wiring in Ruby helpers (message_area_tag) so views stay declarative and the contract lives in one place. - Read page context from
<meta>tags into a tinywindow.Currentobject rather than inlining JSON. - Will elements added later via broadcast get the behavior? Design controllers to handle dynamically-inserted children (e.g.
targetConnectedcallbacks).
Scroll & Interaction Contracts
- Preserve scroll declaratively: pass a custom attribute (
maintain_scroll: true) on broadcasts and handle it once in aturbo:before-stream-renderlistener. - Serialize competing scroll mutations through a promise queue when streams and optimistic inserts race.
- Strip a stream target's
idonturbo:submit-startso background broadcasts can't race the form's own stream response. - Infinite feeds: IntersectionObserver sentinels in turbo-stream pagination (remove trigger, append batch, append new sentinel); cap DOM size; only autoscroll when the user is at the latest page.
ActionCable / Connection Safety
- Authenticate in
Connection#connectwith the same identity resolution as HTTP; scope channel subscriptions through ownership (current_user.rooms.find_by(id: params[:room_id])). - Disconnect deactivated/banned users remotely:
ActionCable.server.remote_connections.where(current_user: user).disconnect. - Presence: reference-count connections with a TTL (
connectionscounter +connected_at, 60s freshness scope) to survive multi-tab and reconnects; debounce visibility changes (~5s) to avoid flicker. - Under path-based tenancy, emit the cable URL from
request.script_namevia a custom meta tag helper.
Caching + Realtime
- Keep cache keys aligned with what affects output (record, user, timezone, filter state); use
touch: truechains so child edits invalidate parents. - Personalize cached fragments client-side: render
data-creator-id, let a Stimulus controller compare againstCurrent.userand toggle visibility — don't break fragment caching for per-user toggles. - Avoid HTTP-caching form pages where token freshness is critical.
Web Push (when applicable)
- Push only to disconnected users, excluding the actor; respect per-user involvement levels (everything vs mentions-only).
- Deliver via a thread pool with persistent HTTP connections, resolving all AR data before posting to threads; invalidate expired subscriptions async.
- Clean up the push subscription client-side on logout.
Testing
- Model broadcasts:
assert_turbo_stream_broadcasts([user, :notifications], count: 1) { ... }andassert_no_turbo_stream_broadcastsfor negatives. - Controller responses:
as: :turbo_stream+assert_turbo_stream action: :replace, target: dom_id(...). - For DOM-level assertions on broadcasts, a small helper reading
ActionCable.server.pubsub.broadcasts+assert_selectcovers it. - System tests:
using_session("OtherUser")for multi-user realtime, and wait forturbo-cable-stream-source[connected]before asserting.
Red Flags
- Duplicate stream IDs/targets causing unstable updates.
- Broadcast channels or stream names that are not tenant/user-scoped.
- HTML rendering inside bare ActionCable channels (use Turbo Streams).
- Stimulus controllers leaking timers/listeners after navigation.
- Replacing full pages for small interactions that should be streamed.
- Custom ActionCable channels for things
turbo_stream_fromalready does. - Morph refreshes destroying in-progress user input (missing
data-turbo-permanent).
Comments
Markdown supported