# Rails Hotwire Realtime

> Apply Hotwire, Turbo, Stimulus, and ActionCable best practices for real-time Rails interfaces. Use when building Turbo Streams/Frames, Stimulus interactions, or websocket-driven updates.

- Skill: `marckohlbrugge/rails-hotwire-realtime` (Agent Skill)
- Install (CLI): `npx skillmds@latest add marckohlbrugge/rails-hotwire-realtime`
- Raw SKILL.md: https://api.skillmd.com/api/skills/marckohlbrugge/rails-hotwire-realtime/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: marckohlbrugge (https://skillmd.com/u/marckohlbrugge)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/marckohlbrugge/rails-hotwire-realtime

---


# Rails 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: :lazy` and give the frame endpoint its own `fresh_when` ETag. Extract frequently-changing fragments into their own frames so they don't bust the parent cache.
- Use `data-turbo-permanent` for 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::Broadcasts` concern with `broadcast_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_refreshes` for direct subscribers plus `broadcasts_refreshes_to ->(r) { [r.account, :aggregate_view] }` for account-wide views.
- Gate noisy secondary broadcasts on meaningful change: set a flag in `before_update` when preview-relevant fields change; broadcast `if: :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_string` once, then `broadcast_replace_to user, ..., html:` per recipient.
- Use `broadcast_*_later` async variants when synchronous broadcasting hurts request latency.
- Broadcast-rendered partials lack request context: wrap attachment/url helpers (`broadcast_image_tag` pattern) 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 `HeartbeatChannel` gives reliable `connected`/`disconnected` callbacks 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 tiny `window.Current` object rather than inlining JSON.
- Will elements added later via broadcast get the behavior? Design controllers to handle dynamically-inserted children (e.g. `targetConnected` callbacks).

## Scroll & Interaction Contracts

- Preserve scroll declaratively: pass a custom attribute (`maintain_scroll: true`) on broadcasts and handle it once in a `turbo:before-stream-render` listener.
- Serialize competing scroll mutations through a promise queue when streams and optimistic inserts race.
- Strip a stream target's `id` on `turbo:submit-start` so 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#connect` with 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 (`connections` counter + `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_name` via a custom meta tag helper.

## Caching + Realtime

- Keep cache keys aligned with what affects output (record, user, timezone, filter state); use `touch: true` chains so child edits invalidate parents.
- Personalize cached fragments client-side: render `data-creator-id`, let a Stimulus controller compare against `Current.user` and 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) { ... }` and `assert_no_turbo_stream_broadcasts` for 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_select` covers it.
- System tests: `using_session("OtherUser")` for multi-user realtime, and wait for `turbo-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_from` already does.
- Morph refreshes destroying in-progress user input (missing `data-turbo-permanent`).

