$npx -y skills add marckohlbrugge/37signals-skills --skill rails-hotwire-realtimeApply Hotwire, Turbo, Stimulus, and ActionCable best practices for real-time Rails interfaces. Use when building Turbo Streams/Frames, Stimulus interactions, or websocket-driven updates.
| 1 | # Rails Hotwire + Realtime |
| 2 | |
| 3 | Use for Turbo/Stimulus/ActionCable architecture and reviews. Patterns from Campfire (cable-heavy chat) and Fizzy (streams-only kanban). |
| 4 | |
| 5 | ## Choose the Right Realtime Architecture |
| 6 | |
| 7 | Two proven 37signals topologies: |
| 8 | |
| 9 | - **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. |
| 10 | - **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. |
| 11 | |
| 12 | Rule of thumb: Turbo Streams for anything that changes the DOM; bare ActionCable only for tiny ephemeral signals. |
| 13 | |
| 14 | ## Turbo Defaults |
| 15 | |
| 16 | - Prefer server-rendered partial updates over heavy client-side state systems. |
| 17 | - Set global morph refresh in the layout: `turbo_refreshes_with method: :morph, scroll: :preserve`. |
| 18 | - 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). |
| 19 | - 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. |
| 20 | - Use `data-turbo-permanent` for elements that must survive navigation/morph (footer trays, in-progress editors). |
| 21 | - Block morph from clobbering client-owned state (e.g. localStorage-driven collapsed columns) via `turbo:before-morph-attribute` + `preventDefault()`. |
| 22 | - Exempt realtime-heavy pages from Turbo's page cache (`turbo_exempts_page_from_cache`); rely on frame-level ETags instead. |
| 23 | - 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. |
| 24 | |
| 25 | ## Broadcast Patterns |
| 26 | |
| 27 | - Keep broadcast logic on models (`Message::Broadcasts` concern with `broadcast_create`), not in controllers. |
| 28 | - Scope every stream name by tenant/user: `[board.account, :all_boards]`, `[user, :notifications]` — stream names are isolation boundaries. |
| 29 | - Dual streams when needed: `broadcasts_refreshes` for direct subscribers plus `broadcasts_refreshes_to ->(r) { [r.account, :aggregate_view] }` for account-wide views. |
| 30 | - Gate noisy secondary broadcasts on meaningful change: set a flag in `before_update` when preview-relevant fields change; broadcast `if: :preview_changed?`. |
| 31 | - Suppress broadcasts in background jobs that incidentally touch records (`Model.suppressing_turbo_broadcasts`), e.g. around ActiveStorage analysis. |
| 32 | - Fan-out efficiently: `render_to_string` once, then `broadcast_replace_to user, ..., html:` per recipient. |
| 33 | - Use `broadcast_*_later` async variants when synchronous broadcasting hurts request latency. |
| 34 | - Broadcast-rendered partials lack request context: wrap attachment/url helpers (`broadcast_image_tag` pattern) so URLs resolve. |
| 35 | |
| 36 | ## Reconnect & Catch-Up |
| 37 | |
| 38 | - Catch-up over reload: on reconnect/tab-return, fetch a turbo-stream diff (`?since=<epoch_ms>`); server appends new records and replaces updated ones. |
| 39 | - An empty `HeartbeatChannel` gives reliable `connected`/`disconnected` callbacks for triggering catch-up and a debounced offline UI. |
| 40 | - Guard Stimulus `connect()` with a turbo-preview check so back/forward cache previews don't open sockets or request permissions. |
| 41 | |
| 42 | ## Stimulus Defaults |
| 43 | |
| 44 | - Keep controllers single-purpose; compose multiple small controllers on one element. |
| 45 | - Prefer targets/values over ad-hoc selectors and attribute parsing. |
| 46 | - Always clean up timers/listeners/subscriptions in `disconnect`. For cable subscriptions, defer unsubscribe one animation frame and skip if the element reconnected (morph churn). |
| 47 | - Use event dispatch between controllers (or outlets) instead of tight coupling. |
| 48 | - 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. |
| 49 | - Read page context from `<meta>` tags into a tiny `window.Current` object rather than inlining JSON. |
| 50 | - Will elements added later via broadcast get the behavior? Design controllers to handle dynamically-inserted children (e.g. `targetConnected` callbacks). |
| 51 | |
| 52 | ## Scroll & Interaction Contracts |
| 53 | |
| 54 | - Preserve scroll declaratively: pass a custom attribute (`maintain_scroll: true`) on broadcasts and handle it once in a `turbo:before-stream-render` listener. |
| 55 | - Serialize competing scroll mutations through a promise queue when streams and optimistic inserts race. |
| 56 | - Strip a stream target's `id` on `turbo:submit-start` so background broadcasts |