Most collaborative apps break down under pressure:
Edits vanish. Cursors clash. Users step on each other’s work.
But with Phoenix LiveView and CRDTs, you can build conflict-free, multi-user interfaces that feel as smooth as Google Docs — all from the server.
The Challenge: True Collaboration
If two users type at once, what happens?
With naïve state management, one user “wins” and the other’s edit gets dropped or overwritten.
To solve this, you need:
- ⚡ Instant updates over WebSockets
- 🔀 Mergeable data structures
- 🖥️ Shared presence, cursors, highlights
- 🧠 State that survives latency and disconnects
LiveView gives you the transport and rendering.
CRDTs give you the consistency.
Step 1: Broadcast User Actions
Each edit is a discrete operation, not a blob of text.
Phoenix.PubSub.broadcast(MyApp.PubSub, "doc:123", %{
op: :insert,
pos: 24,
char: "a",
user_id: "u9"
})
In your LiveView:
def mount(%{"doc_id" => doc_id}, _session, socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "doc:#{doc_id}")
{:ok, assign(socket, crdt: MyCRDT.new())}
end
def handle_info(%{op: _, pos: _, char: _, user_id: _} = change, socket) do
updated_crdt = MyCRDT.apply(socket.assigns.crdt, change)
{:noreply, assign(socket, crdt: updated_crdt)}
end
Step 2: Use a Mergeable CRDT
Use a sequence-based CRDT (like RGA or Yjs) to track insertions and deletions.
You can use:
-
delta_crdt_ex
for peer sync - A custom GenServer that holds the CRDT and rebroadcasts deltas
- An in-memory copy per LiveView for performance
Each client maintains their own copy. Changes are applied in the same order everywhere — no locks, no overwrites.
Step 3: Render the Shared State
<div class="prose whitespace-pre-wrap font-mono">
<%= for char <- MyCRDT.to_list(@crdt) do %>
<%= char %>
<% end %>
</div>
LiveView only re-renders the changed text, thanks to smart DOM patching.
Result?
Millisecond updates. Zero flicker. Scales with document size.
Step 4: Show Live Cursors + Presence
Use Phoenix.Presence
to track active users:
presence_list = Phoenix.Presence.list(MyApp.Presence, "doc:123")
# Example result:
%{
"u9" => %{metas: [%{cursor: {2, 18}, name: "Alice"}]},
"u5" => %{metas: [%{cursor: {7, 43}, name: "Bob"}]}
}
In the template:
<%= for {user_id, %{metas: [meta | _]}} <- @presence do %>
<div
class="absolute w-2 h-4 bg-blue-300 rounded-sm transition-all"
style={"left: #{meta.cursor.x}px; top: #{meta.cursor.y}px;"}
title={meta.name}
></div>
<% end %>
Each user gets a cursor. You can animate them, style them, or show tooltips.
Step 5: Persist & Replay
Batch operations into an event log:
Repo.insert!(%ChangeEvent{doc_id: "123", payload: change})
On reconnect:
- Replay events
- Rebuild the CRDT
- Re-sync local state
CRDTs guarantee that merge order doesn’t matter. You always converge.
Extend to Whiteboards, Diagrams, Tables
Text is just the start.
You can make each visual element a CRDT item:
%{
id: "shape-42",
type: :circle,
x: 200,
y: 150,
radius: 40
}
On drag:
Phoenix.PubSub.broadcast(MyApp.PubSub, "whiteboard:room1", %{
op: :move,
id: "shape-42",
x: 260,
y: 180,
user_id: "u3"
})
Each change is mergeable. Each canvas stays consistent.
Use Tailwind (absolute
, transform
, transition
, etc.) to animate it all without JS.
Why This Works
✅ CRDTs make your state conflict-free
✅ LiveView makes your UI real-time
✅ PubSub + Presence makes it collaborative
✅ One stack — all Elixir — no frontend framework needed
You don’t need React. You don’t need Firebase.
You need Phoenix + CRDTs + good architecture.
Ready to Build Collaborative Tools That Scale?
Download the PDF:
Phoenix LiveView: The Pro’s Guide to Scalable Interfaces and UI Patterns
Inside you’ll find:
- Shared editing patterns
- CRDT data models
- Real-time cursor UX
- Tailwind-driven styling
- Event log persistence
- Presence-based UI design
Whether you’re building a team notes app, a live whiteboard, or a multiplayer canvas — you can make it fast, safe, and seamless with LiveView.
No hacks. No JS sprawl. Just Elixir, done right.
Top comments (0)