Building Real-Time Collaboration Canvas using Hotwire and Turbo Streams
A canvas is a bad reason to go SPA
Real-time collaboration features are where teams justify a React frontend: “we need sockets and shared state.” But most collaboration features are CRUD with a delivery mechanism – someone updates a document and everyone else’s screen catches up. With Rails 7.1 and Hotwire you get that with server-rendered HTML: Turbo Streams over ActionCable for the transport, a Stimulus controller for the pointer events, and no client-side state tree, no API contract, and no double rendering. The trade is real – the server pays a render per update and you give up fine-grained optimistic UI – but the models stay the source of truth, which is worth more than a snappy drag preview in a collaboration tool.
Streaming model changes to subscribers
The whole pattern is two pieces. The view subscribes to a per-document stream:
1
2
3
4
5
6
<%# app/views/documents/show.html.erb %>
<%= turbo_stream_from "document_#{@document.id}" %>
<div id="canvas" data-controller="canvas" data-canvas-document-id-value="<%= @document.id %>">
<%= render "documents/canvas", document: @document %>
</div>
And the model broadcasts on commit. We use the _later variants so the render happens in a background job, never in the request thread:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Document < ApplicationRecord
has_many :strokes, dependent: :destroy
after_commit :broadcast_canvas
private
def broadcast_canvas
broadcast_replace_later_to "document_#{id}",
target: "canvas",
partial: "documents/canvas",
locals: { document: self }
end
end
When a stroke commits, the job re-renders the partial on the server, streams the HTML down the document’s channel, and Turbo swaps the inner HTML of #canvas. No new route, no JSON, no client-side model. Every subscriber on the stream gets the update, and the Redis adapter fans it out across multiple Puma workers.
Debounce the writes, not just the renders
The failure mode is broadcast storms. A canvas pointermove fires 120+ times a second; if every event commits to the database, you get an insert per frame per user and a broadcast per insert. That is why broadcast_replace_later_to alone is not enough – the job runs per commit, and at dozens of commits a second the partial renders become the bottleneck. Our Stimulus controller coalesces at the input layer:
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
// app/javascript/controllers/canvas_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.pending = false
this.element.addEventListener("pointermove", this.schedule.bind(this))
}
schedule(event) {
if (this.pending) return
this.pending = true
requestAnimationFrame(() => {
this.pending = false
this.commit(event)
})
}
commit(event) {
fetch("/strokes", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf() },
body: JSON.stringify({
document_id: this.documentIdValue,
x: event.offsetX,
y: event.offsetY
})
})
}
}
The Stimulus value API (data-canvas-document-id-value) is the clean way to pass the id into the controller – no inline scripts, no querying the DOM. Coalescing pointer events to a requestAnimationFrame keeps the UI responsive but still produces up to 60 commits per second, so we go further: buffer points into a stroke segment and flush every ~150ms. That drops the write rate from 60/s/user to around 6/s/user, and each broadcast then carries a meaningful delta instead of a single pixel.
Fan-out at scale
A partial render in the job costs roughly 10-20ms. With 200 editors on a busy document at 6 writes per second each, that is about 1,200 renders per second and a single job worker drowns. We coalesce per document: any commit within the same ~200ms window bumps one shared broadcast instead of one per commit, which cut render volume about 5x on our busiest documents. Redis pub/sub handles the actual fan-out across processes; the async adapter is a development convenience and will not scale past one process.
Production lessons
- Use
broadcast_*_later_toso the request thread never renders for subscribers; always. - Coalesce at the write layer (segment flush), not just at the render layer – broadcasts are downstream of commits.
- Redis is the production ActionCable adapter;
asyncis single-process only and dies the moment you scale out. - On busy documents, broadcast whole-document deltas on a short window rather than per-stroke broadcasts.
- Pass ids into Stimulus with the value API; the
data-*attributes are the contract between view and controller. - For per-user cursors, stream a lightweight presence channel on a heartbeat instead of mirroring every pointer event; your subscribers do not need 120 frames a second of someone else’s mouse.