Skip to content

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:

1
2
# config/routes.rb
mount ActionCable.server => "/cable"

And configured for development:

1
2
3
# config/cable.yml
development:
  adapter: async

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# app/models/card.rb
class Card < ApplicationRecord
  belongs_to :column, touch: true

  scope :ordered, -> { order(:position) }

  before_create :set_position

  private

  def set_position
    self.position = column.cards.count
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# app/models/column.rb
class Column < ApplicationRecord
  belongs_to :board, touch: true
  has_many :cards, dependent: :destroy

  scope :ordered, -> { order(:position) }

  before_create :set_position

  private

  def set_position
    self.position = board.columns.count
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# app/models/board.rb
class Board < ApplicationRecord
  belongs_to :user
  has_many :owned_boards, class_name: "Board", foreign_key: :user_id,
           dependent: :destroy
  has_many :memberships, dependent: :destroy
  has_many :members, through: :memberships, source: :user
  has_many :columns, -> { order(:position) }, dependent: :destroy
  broadcasts_refreshes

  validates :name, presence: true, length: { maximum: 100 }
end

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# app/views/boards/show.rb
class Views::Boards::Show < Views::Base
  def page_title = @board.name

  def initialize(board:)
    @board = board
  end

  def view_template
    turbo_stream_from @board

    render_header
    render_board
  end

  # ... rest unchanged
end

turbo_stream_from is a Phlex Rails helper. Confirm it’s included in Components::Base:

1
2
# app/components/base.rb
include Phlex::Rails::Helpers::TurboStreamFrom

How it works end to end

When User A adds a card:

  1. CardsController#create saves the card and redirects to the board
  2. card.save touches the column via touch: true
  3. The column touch touches the board via touch: true
  4. broadcasts_refreshes on Board schedules a background job
  5. The job broadcasts a refresh Turbo Stream action to the board’s ActionCable channel
  6. User B’s browser receives the broadcast on its open WebSocket connection
  7. Turbo fetches User B’s current page URL — the same board view
  8. The response passes through the normal Rails stack — User B only sees what they’re authorised to see
  9. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// app/javascript/controllers/card_form_controller.js
// app/javascript/controllers/column_form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["display", "form", "input", "error"]

  showForm() {
    this.displayTarget.hidden = true
    this.formTarget.hidden = false
    this.inputTarget.focus()
  }

  hideForm() {
    this.formTarget.querySelector("form")?.reset()
    this.displayTarget.hidden = false
    this.formTarget.hidden = true
    this.clearError()
  }

  submit(event) {
    if (this.inputTarget.value.trim() === "") {
      event.preventDefault()
      this.showError("Name can't be blank")
    }
  }

  showError(message) {
    this.errorTarget.textContent = message
    this.errorTarget.hidden = false
  }

  clearError() {
    this.errorTarget.textContent = ""
    this.errorTarget.hidden = true
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// app/javascript/controllers/column_form_controller.js
// app/javascript/controllers/column_form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["display", "form", "input", "error"]

  showForm() {
    this.displayTarget.hidden = true
    this.formTarget.hidden = false
    this.inputTarget.focus()
  }

  hideForm() {
    this.formTarget.querySelector("form")?.reset()
    this.displayTarget.hidden = false
    this.formTarget.hidden = true
    this.clearError()
  }

  submit(event) {
    if (this.inputTarget.value.trim() === "") {
      event.preventDefault()
      this.showError("Name can't be blank")
    }
  }

  showError(message) {
    this.errorTarget.textContent = message
    this.errorTarget.hidden = false
  }

  clearError() {
    this.errorTarget.textContent = ""
    this.errorTarget.hidden = true
  }
}

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:

1
2
3
after_create_commit  { broadcast_refresh_later }
after_update_commit  { broadcast_refresh_later }
after_destroy_commit { broadcast_refresh_later }

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_refreshes is on Board
  • Confirm touch: true is on both Card#belongs_to :column and Column#belongs_to :board
  • Confirm turbo_stream_from @board is in the view template and rendering a <turbo-cable-stream-source> element in the page source
  • Confirm the cable adapter is async in config/cable.yml
  • Check the Rails log for ActionCable broadcast 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:

1
2
3
4
5
6
7
# app/views/layouts/app_layout.rb
def view_template
  html(id: "html-root", lang: "en", data: { turbo_permanent: true }) do
    render_head
    render_body
  end
end

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-timebroadcasts_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::KanbanColumn
  • Components::KanbanCard

Views built this module

  • Views::Boards::Show — the real board view
  • Views::Cards::CardForm — single form for add and edit
  • Views::Columns::ColumnForm — single form for add and edit

Stimulus controllers written this module

  • board_controller.js — Sortable drag and drop for cards and columns
  • card_form_controller.js — inline add/edit toggle for cards
  • column_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.