Fable and Opus build a multiplayer feature
Who won?
Last week I shipped a Google Docs style multiplayer collaboration for our kanban app - Fizzy (our fork with some blows and whistles): Ruby, Rails, Hotwire, Lexxy, AnyCable joined by a couple CRDTs: Yjs on the client, a Rust y-crdt document on the server via yrby, relayed over AnyCable channels and whispers.
I thought it’d be cool to run an experiment: compare Fable to Opus on this feature. I rolled a clone back to the commit right before the feature started, and had Fable 5 and Opus 4.8 each rebuild it from the identical kickoff:
let’s build real collaborative editing of a card’s rich-text description with lexxy + y.js + anycable
Plus a short spec of what “done” means.
I set three things to make the comparison fair:
Pre-wired the transport. A running AnyCable stack: a WebSocket server, an RPC process, signed JWT auth.
Fixed a shared hint ladder. After the autonomous first pass, each model got the same design nudges at the same points: “drop the Edit button, make it inline,” then “fix image uploads,” then “make the whole card collaborative.” Any bug a model created was its own to fix and counted against it.
Captured everything. Prompt counts, tool calls, token usage, wall-clock, and the full transcript, so “it felt more autonomous” could become an actual number.
Here’s the run, in numbers:
How they opened: both think first
Both models front-loaded a lot of planning before writing a line of code. Opus spun up around nine research subagents to nail down the exact yrby API and the lexxy version story; Fable did its heavy thinking in the main session, working through the same lexxy 0.7-to-0.9.20 upgrade. For a good half hour, each clone sat at zero lines written while the model read and planned.
One of them tested its own work
Fable drove its own two-tab tests. After it wrote the inline editor, it opened the scenario itself and reported back:
“dropped image in tab B, real image rendered in tab A.”
It closed its own loop before handing the result to me. Opus kept declaring victory from the unit tests and left the live check to me. And the live check kept failing in ways the unit tests couldn’t see:
The first version passed CI green, then crashed the instant two real tabs connected. Its channel assumed
current_userwas a User object. Under the JWT cable auth we use, it’s actually a plain string, so the first thing it did on connect was call a method on the wrong type.Autosave started returning 406. The background save fired a
PATCHthat expected an HTML response the controller didn’t offer, so every keystroke’s persistence failed.A dropped image poisoned the shared document. The attachment node threw when Yjs rebuilt it for the other peer, and because the broken node was now in the shared doc, every later sync re-threw. One bad image bricked the session for everyone.
The title silently refused to sync between tabs, with no error at all.
“Green tests” and “it works when two people use it” are different claims, and only one model consistently checked the second one.
Fewer nudges
Fable reached a working, running state with zero bug-fix interventions and needed four prompts total across the whole run. Opus needed eight, including a hint just to get past its own subscribe crash.
And Fable delivered more on its own. When I asked both to make the whole card collaborative (title and checklist, not only the description), Opus never got the checklist syncing even with the hint. Fable had checklist collaboration working from its autonomous first pass, before I asked for it.
The fork that wasn’t needed
The hardest bug in the original build was image attachments, and it has a nice twist for this experiment. The released version of our editor library, lexxy, has node constructors that throw when Yjs reconstructs them for a remote peer. So dropping an image crashed everyone else’s tab. I solved it by forking lexxy. Either model could have found the fork and pinned to it in one line.
Neither did. Both derived their own fix and left the dependency alone. They took two different routes to the same crash. Opus kept the un-serializable fields out of the shared document, so a peer never tries to rebuild a node from data that can’t survive the trip:
// Keep the un-serializable fields (a File, a live editor, a blob: URL)
// out of the snapshot @lexical/yjs writes to the shared doc.
const UNSYNCABLE = new Set(["editor", "file", "previewSrc"])
function excludeNonSerializableAttachmentProps(binding) {
for (const [type, props] of binding.nodeProperties) {
const filtered = {}
for (const [key, value] of Object.entries(props)) {
if (!UNSYNCABLE.has(key)) filtered[key] = value // only real attachment data
}
binding.nodeProperties.set(type, Object.freeze(filtered))
}
}Fable went further (and probably too far) and built a routine that spins up a throwaway editor, finds which node classes choke on no-argument construction, and subclasses them for the whole session so that remote attachments and checklist items rebuild instead of throwing.
// Probe a throwaway editor to find node classes that throw on no-arg
// construction (what @lexical/yjs does when rebuilding a REMOTE node),
// then swap each into a subclass that supplies {} instead of throwing.
function guardNoArgNodeConstruction(editor) {
// ...collect the custom node classes into `candidates`...
probeEditor.update(() => {
for (const candidate of candidates) {
try { new candidate.klass() } // survive construction with no args?
catch { throwers.push(candidate) } // no -> this one needs guarding
}
}, { discrete: true })
throwers.forEach(({ info, klass }) => {
info.klass = class extends klass { // same node identity, safe ctor
constructor(...args) {
super(args.length === 0 || args[0] === undefined ? {} : args[0], args[1])
}
}
})
}That broader guard is why Fable’s checklists synced autonomously while Opus’s never did.
This did leave me with the question though: do I want this defensive and, in some sense, product-agnostic code running in production?
Where they both fell short
Neither model cracked title sync, and they failed it the same way for a subtle reason. Both wrapped too much of the card in Turbo’s data-turbo-permanent, the attribute that tells Turbo “don’t touch this on navigation,” to protect the live editor from being destroyed by page morphs. In casting that net too wide, they excluded the title (Fable) or the checklist (Opus) from ever updating for the other viewer.
The code, side by side
I had both implementations reviewed as a structured layered-architecture pass, the kind we run on our own Rails work.
Opus is the tidier read. It pinned exact dependency versions and left the app’s security and storage plumbing untouched. Where it goes wrong is deeper: its cable channel scopes to every card in the whole account and checks access only when you first subscribe, so the load and save paths sit one refactor away from being unguarded. Most serious of all, it built no seed path: it binds a fresh empty document over a card that already has a description, so an existing card can be wiped the first time two people open it together, then saved back empty. That is a data-loss bug sitting under green tests.
Fable is sturdier where it counts, and messier where you don’t look. It has the seed guard Opus lacks, the node-construction guard from earlier that keeps remote attachments and checklists alive, a gap-guard that refuses to compact a document with missing pieces, transparent bundling, and real regenerable Yjs test fixtures with genuine lifecycle tests. But the layered pass caught things. It mutates global tenant state inside a method that reads like a plain getter. It piles the CRDT merge logic onto an already-enormous Card model while leaving the tiny model that actually owns the rows nearly empty. And it bundles untested rewrites of CSRF and file-storage behavior into the feature commit. Its shipped bugs are still the shallow ones, the frozen title and the 406, but its architecture has its own real dents.
Here’s the part I find most telling. Take compaction, the job that folds the update log into a single row. Both models wrote it, and here it is in each one’s hands:
Opus:
# A merge of Yjs updates is itself a valid update, so a snapshot is just
# a merged row (no special "kind" column). Tidy.
def self.compact!(card)
transaction do
rows = card.collaboration_updates.chronologically.lock.to_a
return if rows.size <= 1
merged = merge(rows.map(&:data)) # CPU-bound replay, inside the lock
card.collaboration_updates.create!(data: merged)
where(id: rows.map(&:id)).delete_all
end
endFable:
# Same shape, plus a gap-guard: never compact a doc that still has causally
# missing pieces, or the merge would silently drop their bytes.
def compact_description_updates
transaction do
rows = description_updates.chronologically.lock.to_a
if rows.size > 1
doc = Y::Doc.new
rows.each { |row| doc.apply_update(row.payload) } # replay, inside the lock
unless doc.pending? # the guard Opus lacks
description_updates.create!(payload: doc.compacted_state_update, kind: "snapshot")
description_updates.where(id: rows.map(&:id)).delete_all
end
end
end
endOpus’s reads cleaner (no kind column, because a merge of updates is itself a valid update). Fable’s is safer (the gap-guard Opus lacks). But look at what both of them do: replay the entire document inside the transaction, holding the row locks. In my pass, we extracted this into a small object that replays with no locks held and then commits in one short transaction. The one extraction that keeps the database healthy under load is the one they both missed.
So, which one would you rather maintain?



