Lesson 6 — Real-time updates with Turbo Broadcasts
The multi-user problem
Everything built so far works perfectly for a single user. When two users have the same board open and one adds a card, the other sees nothing until they manually refresh.
Fixing this requires two things: a way for the server to push a signal to connected browsers, and a way for those browsers to update their view in response. ActionCable handles the push. Turbo Morph handles the update.
Why broadcasting is simple with Morph
Before Turbo 8, real-time updates required broadcasting specific DOM operations — append this card HTML, replace this column header, remove this card. Every change needed its own stream template. Keeping those templates in sync with the rest of the UI was error-prone and time-consuming.
With morphing, the server broadcasts a single signal: “this board has changed, go fetch the latest version.” Each subscribed browser makes a GET request for the current page and morphs the result. The server renders one thing — the full board view — and Turbo handles the diff.
This means:
- No stream templates to maintain
- No risk of broadcasting HTML the recipient isn’t authorised to see (each browser fetches its own page through the normal auth stack)
- One broadcast triggers updates for all changes — cards, columns, positions, names
Setting up ActionCable
ActionCable ships with Rails 8. Verify it’s mounted in your routes:
|
|
And configured for development:
|
|
The async adapter runs in-process — no Redis or additional
infrastructure needed for development. Rails 8 uses Solid Cable in
production by default.
Propagating changes up to the board
We want the board to broadcast whenever anything on it changes —
cards added, moved, or deleted; columns renamed, added, or deleted.
The cleanest way to propagate these changes up to the board is with
touch: true on the associations.
touch: true updates the parent’s updated_at timestamp whenever the
child record changes. With broadcasts_refreshes on Board, any touch
triggers a broadcast:
|
|
|
|
|
|
The chain: a card changes → its column is touched → the column change touches the board → the board broadcasts a refresh.
broadcasts_refreshes adds model callbacks that broadcast a Turbo
Stream refresh action via ActionCable whenever the record is created,
updated, or destroyed.
Subscribing in the board view
Add turbo_stream_from to Views::Boards::Show. This opens an
ActionCable subscription — when the board broadcasts a refresh, Turbo
receives it and morphs the page:
|
|
turbo_stream_from is a Phlex Rails helper. Confirm it’s included in
Components::Base:
|
|
How it works end to end
When User A adds a card:
CardsController#createsaves the card and redirects to the boardcard.savetouches the column viatouch: true- The column touch touches the board via
touch: true broadcasts_refreshesonBoardschedules a background job- The job broadcasts a
refreshTurbo Stream action to the board’s ActionCable channel - User B’s browser receives the broadcast on its open WebSocket connection
- Turbo fetches User B’s current page URL — the same board view
- The response passes through the normal Rails stack — User B only sees what they’re authorised to see
- Turbo morphs the result — the new card appears in the column, scroll position preserved
User A also receives the broadcast — but Turbo is smart enough to ignore refreshes triggered by the user’s own request, using a request ID header to deduplicate.
Protecting open edit forms from morphs
When a broadcast morph arrives, any open edit form would be wiped — the morph replaces the card or column HTML with the server’s version, discarding unsaved input.
The fix is to mark an element data-turbo-permanent only while it’s
being edited, and remove it when editing is done. The Stimulus
controllers handle this:
|
|
|
|
showForm sets data-turbo-permanent on the nearest ancestor with an
id — the card or column wrapper which already has dom_id set. This
protects the element from morphing while editing. hideForm removes
the attribute, returning the element to the morph pool.
handleMorph listens for turbo:morph events. If a morph arrives
while the form is open — because another user made a change — the form
closes cleanly. The user loses their unsaved input, but this only
occurs in the edge case where another user modifies the same card or
column simultaneously. It’s an acceptable tradeoff at this stage.
What broadcasts_refreshes is actually doing
It’s worth demystifying this. broadcasts_refreshes is a convenience
method from turbo-rails that adds model callbacks equivalent to:
|
|
broadcast_refresh_later schedules a background job that sends a
Turbo Stream refresh action over ActionCable to a channel named after
the record. turbo_stream_from @board in the view subscribes to that
channel.
The refresh action is not HTML — it’s a tiny signal that tells the
subscribed browser to fetch the current page. That’s why there are no
stream templates to maintain and no authorisation concerns. The browser
does its own fetch, through its own session.
Testing real-time updates
Open the same board in two browser windows. In one window, add a card. Within a second or two it should appear in the other window without any manual refresh. Scroll position should be preserved.
Check the browser DevTools Network tab — you should see a WebSocket
connection to /cable in both windows. The Messages tab on that
connection will show the incoming refresh stream action when a change
is broadcast.
If updates aren’t appearing:
- Confirm
broadcasts_refreshesis onBoard - Confirm
touch: trueis on bothCard#belongs_to :columnandColumn#belongs_to :board - Confirm
turbo_stream_from @boardis in the view template and rendering a<turbo-cable-stream-source>element in the page source - Confirm the cable adapter is
asyncinconfig/cable.yml - Check the Rails log for
ActionCablebroadcast and transmit messages
Protecting page-level state from morphs
Broadcast morphs re-render the full page, which includes the <html> element. Any state held in<html> attributes — theme, dark mode class — gets wiped by the morph and briefly flashes the default before the Stimulus controller reconnects and restores it.
The fix is to mark the <html> element as data-turbo-permanent. This requires a stable id:
|
|
With this in place, morph skips the <html> element entirely — theme and dark mode state survive broadcast morphs unchanged.
This is a general principle: any element that holds client-side state that shouldn’t be reset by a server render needs data-turbo-permanent with a stable id. The <html> element is the most common case because it’s where CSS custom properties and theme classes live.
A note on performance
Broadcasting at the board level means every change to any card or column triggers a full board re-render on every connected browser. For a board with many columns and cards and many concurrent users, this could generate significant load.
This is a deliberate tradeoff at this stage of the app — simplicity over optimisation. The performance module revisits this, covering fragment caching, more granular broadcasts at the column level, and strategies for boards with high concurrent usage.
For most real-world Kanban boards — a few dozen cards, a handful of concurrent users — board-level broadcasting is perfectly adequate.
Module 9 summary
The new Hotwire mental model
The central lesson of this module is that Turbo 8 changed the default. Frames and Streams are no longer the first tools to reach for. The decision hierarchy we followed throughout:
1. Morph first — enable with two meta tags in the layout head, controllers redirect as normal. Handles all CRUD without any explicit Turbo wiring. Adding a board, editing a column name, deleting a card — all handled by redirect + morph with no additional code.
2. Stimulus toggle for inline interactions — for interactions that shouldn’t navigate away from the board. The add card, edit card, edit column, and add column forms are all inline toggles. The form is in the DOM, hidden by default, shown and hidden by Stimulus. No Turbo involved.
3. Client-side validation for single fields — rather than handling
422 responses for a single required field, validate before submission
in the Stimulus controller. Inline error, no server round-trip, no
complex re-render path needed.
4. Broadcasts for real-time — broadcasts_refreshes on the board
model, touch: true on associations to propagate changes upward,
turbo_stream_from @board in the view. Real-time multi-user updates
with no stream templates and no authorisation concerns.
Drag and drop sits outside the Turbo hierarchy entirely — it’s a Stimulus and Sortable.js concern, with a PATCH endpoint to persist positions.
Turbo Frames and Streams were not needed to build a fully interactive, real-time Kanban board. They remain available for specific use cases — lazy loading (Frames) and surgical DOM updates (Streams) — but neither was required here.
What we built
The board view is fully self-contained. Every interaction happens without leaving the board:
- Add card — inline toggle, submit, morph
- Edit card title — inline toggle, submit, morph
- Delete card — confirm dialog, morph
- Edit column title — inline toggle, submit, morph
- Delete column — confirm dialog with card count, morph
- Add column — inline toggle, submit, morph
- Reorder cards — drag within or between columns, PATCH position
- Reorder columns — drag by header handle, PATCH position
- Real-time sync — broadcast refresh, morph for all connected users
Components built this module
Components::KanbanColumnComponents::KanbanCard
Views built this module
Views::Boards::Show— the real board viewViews::Cards::CardForm— single form for add and editViews::Columns::ColumnForm— single form for add and edit
Stimulus controllers written this module
board_controller.js— Sortable drag and drop for cards and columnscard_form_controller.js— inline add/edit toggle for cardscolumn_form_controller.js— inline add/edit toggle for columns
KanbanFlow progress
KanbanFlow now has a fully interactive, real-time board. Cards and columns can be created, edited, deleted, and dragged — all without leaving the board. Two users on the same board see each other’s changes within seconds. All of this with conventional Rails controllers, no stream templates, and three focused Stimulus controllers.