- Published on
Realtime Collaboration & CRDT 2026 Deep Dive — Liveblocks, PartyKit, Yjs, Automerge, Loro, ShareDB, Replicache, Fluid, Tldraw sync
- Authors

- Name
- Youngju Kim
- @fjvbn20031
Prologue — What "Editing Together" Means in 2026
When Google Docs gave ordinary users simultaneous document editing in 2010, the underlying technology was OT (Operational Transform): transforming one person's edit operation against another's. It worked, but it was server-centric, offline was hard, and a buggy transformation function would haunt you forever.
By May 2026, the landscape has flipped. CRDTs (Conflict-free Replicated Data Types) are now the default unit of web collaboration. Figma's multiplayer infrastructure, Linear's local-first sync, Notion's live cursors, tldraw's realtime canvas, Excalidraw's collaboration rooms — all of these stand on CRDTs or their cousins.
Two larger shifts compound this.
- November 2025: Vercel folded Liveblocks and PartyKit into close partnerships within months of each other, cementing a "Vercel for Realtime" positioning.
- The Ink & Switch lab's "Local-first software" manifesto is no longer academic curiosity. Automerge 2.x, Loro, and Yrs (the Rust port of Yjs) have shipped in production.
This essay walks the 2026 realtime stack in one breath. Theory (CRDT vs OT, vector clocks, Lamport), library comparisons (Yjs vs Automerge vs Loro), SaaS comparisons (Liveblocks vs PartyKit vs Hocuspocus), and operational patterns (WebSocket vs WebRTC, awareness, persistence).
Chapter 1 · The Essence of Simultaneous Editing — Same Document, Different Timelines
First, let us define the problem. Two people editing the same document at the same time means, in computer-science terms, two independent operations applied to the same state.
t0: "Hello" (server = client A = client B)
t1: client A inserts ", world" at position 5
t1: client B inserts "Say: " at position 0
(neither knows about the other)
t2: both operations meet at the server
-> what should the result be?
There are two answers. OT "transforms each operation to fit the other's coordinate system." CRDTs "assign absolute, unique IDs from the start so the operations can be applied as-is."
OT assumes a single source of truth (the server), but CRDTs let every client converge to the same state with or without a server (called eventual consistency). That property is the foundation of local-first software.
Chapter 2 · CmRDT vs CvRDT — Operations or State
CRDTs split into two families.
- CmRDT (Operation-based): propagate operations. "Insert 'x' at position 5" is sent. Messages are small but require exactly-once delivery or causal ordering.
- CvRDT (State-based): propagate the whole state or chunks of it. A merge function combines two states at sync time. The merge must be commutative, associative, and idempotent.
Yjs and Automerge both started op-based, but the 2026 generation — Loro, Y-CRDT, Automerge 2.x — has evolved toward delta-state or hybrid models: send only the diff, but in a form that can be applied idempotently.
| Family | Message Size | Network Guarantees | Representative Library |
|---|---|---|---|
| CmRDT | Small | Causal order + at-least-once | Early Treedoc, RGA |
| CvRDT | Large | Best-effort, idempotent | Early Riak DT |
| Delta-state | Medium | Idempotent + causal metadata | Yjs, Automerge 2.x, Loro |
Chapter 3 · OT vs CRDT — Why CRDTs Won
| Criterion | OT | CRDT |
|---|---|---|
| Central server required | Effectively yes | No (P2P possible) |
| Offline edits | Hard | Natural |
| Verifying transform functions | Notoriously hard (TP2 problem) | Not needed |
| Memory usage | Small | Larger (metadata accrual) |
| Maturity for text | Battle-tested in 2000s | Battle-tested in 2020s |
| Headline users | Google Docs (historical) | Figma, Linear, Notion live cursors |
Google Docs still uses OT (legacy plus a verified infrastructure). But practically every new collaborative app since the 2020s has chosen CRDTs. The biggest reasons are offline support and P2P capability.
Chapter 4 · Vector Clocks and Lamport Timestamps — Two Faces of Distributed Time
Distributed systems have no absolute clock, so we use two tools.
- Lamport timestamp: every event gets a monotonically increasing integer. To compare two nodes, take max + 1. Total order is possible, but you cannot tell whether two events are concurrent.
- Vector clock: carry per-node counters.
[A:3, B:2, C:1]. Comparing two vectors yields exactly one of "happens-before / happens-after / concurrent."
Yjs assigns each client a unique ID and combines client ID + monotonic sequence to identify operations (essentially Lamport plus client ID). Automerge uses actor ID + sequence number (a vector-clock variant). Loro follows the actor + counter model too.
Chapter 5 · Text CRDT Theory — Inside Yjs's Y.Text
Text is the hardest CRDT. Index-based operations like "insert 'x' at position 5" break under concurrent editing (someone inserts at 4 and your "5" is now "6"). So CRDTs use unique-ID-based positioning.
Yjs's Y.Text assigns every character a (clientID, clock) pair and threads them as a double-linked list of Items. An insert becomes "insert after this ID," and a delete leaves a tombstone. Concurrent inserts at the same position are ordered deterministically by clientID.
// Yjs: two people edit the same document
import * as Y from 'yjs'
// Client A
const docA = new Y.Doc()
const textA = docA.getText('content')
textA.insert(0, 'Hello')
// Client B (initially offline)
const docB = new Y.Doc()
const textB = docB.getText('content')
Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA))
textB.insert(5, ', world')
// Meanwhile, client A also edits
textA.insert(0, 'Say: ')
// Exchange updates both ways
Y.applyUpdate(docA, Y.encodeStateAsUpdate(docB))
Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA))
// Both documents converge to the same state
console.log(textA.toString()) // "Say: Hello, world"
console.log(textB.toString()) // "Say: Hello, world"
The crux is encodeStateAsUpdate and applyUpdate. In any order, any number of times, over any path, the result is the same. Idempotent, commutative, associative.
Chapter 6 · Yjs Internals — The Item Linked List and GC
The heart of Yjs is the Item. Each Item has:
id: { client: number, clock: number }— unique identifierorigin: ID | null— "which Item is this inserted after"rightOrigin: ID | null— used to resolve concurrent-insert conflictscontent: ContentString | ContentEmbed | ...— the actual valuedeleted: boolean— tombstone flag
To keep memory in check, Yjs performs GC. Deletions seen by all clients are removed from the tombstone, and adjacent Items from the same client get compacted into blocks (e.g., the five Items typed when you wrote "Hello" become one).
Benchmarks consistently rank Yjs among the fastest text CRDTs. Million-character documents remain practical in memory and CPU.
Chapter 7 · Automerge — The JSON CRDT Standard
Automerge began around the same time as Yjs but focuses on treating whole JSON documents as a CRDT. It supports text (Automerge.Text, recently a splice-based API), but its strength is merging deeply nested object trees.
// Automerge: two people edit the same JSON document
import { from, change, save, load, merge } from '@automerge/automerge'
// Initial document
let doc1 = from({
title: 'Realtime collab notes',
todos: [{ done: false, text: 'Write CRDT post' }],
})
// Serialize and send to another client
const binary = save(doc1)
let doc2 = load(binary)
// Concurrent edits on both sides
doc1 = change(doc1, (d) => {
d.todos.push({ done: false, text: 'Add references' })
d.title = 'Realtime collab notes (draft)'
})
doc2 = change(doc2, (d) => {
d.todos[0].done = true
})
// Merge both ways
const merged1 = merge(doc1, doc2)
const merged2 = merge(doc2, doc1)
// Both merges produce identical results
console.log(JSON.stringify(merged1) === JSON.stringify(merged2)) // true
Automerge 2.x was rewritten in Rust (automerge-rs) and compiled to WASM, running in browsers, Node, and Electron alike. Memory and speed improved by an order of magnitude over 1.x.
Chapter 8 · Loro — The 2024-2026 Dark Horse
Loro is a newer CRDT library out of China that hit 1.0 in 2024 and, by 2026, sits alongside Yjs and Automerge as one of the "big three." Highlights:
- Rust + WASM from day one
- Time travel as a first-class feature — rewind the document to any historical point
- Rich-text mark CRDTs (style ranges) handled with precision in dedicated structures
- Tree CRDTs (concurrent moves under parent-child relationships) treated seriously
Benchmarks show Loro outperforming Yjs on certain workloads (especially trees and rich text). Yjs still has the edge in plain text editing, ecosystem maturity, and learning resources.
Chapter 9 · Liveblocks — The First CRDT-as-a-Service Champion
Liveblocks (Paris-based) was first to nail down the "multiplayer infrastructure as SaaS" proposition. As of 2026:
LiveObject,LiveList,LiveMap— their own CRDTs (separate from Yjs)useStorage/useMutation— React hooks for shared state- Awareness/Presence — realtime cursors, names, selections
- Comments / Threads — a separate module for commenting on documents
- Yjs adapter — existing Yjs apps (Tiptap, BlockNote, Lexical's Yjs adapter) can sync over Liveblocks transport
// Liveblocks: read and write shared state via the useStorage hook
import { useMutation, useStorage } from '@liveblocks/react/suspense'
function Todos() {
const todos = useStorage((root) => root.todos)
const addTodo = useMutation(({ storage }, text: string) => {
const list = storage.get('todos')
list.push({ id: crypto.randomUUID(), text, done: false })
}, [])
const toggle = useMutation(({ storage }, id: string) => {
const list = storage.get('todos')
const idx = list.findIndex((t) => t.id === id)
if (idx >= 0) {
const item = list.get(idx)
list.set(idx, { ...item, done: !item.done })
}
}, [])
return (
<ul>
{todos.map((t) => (
<li key={t.id} onClick={() => toggle(t.id)}>
{t.done ? 'X' : 'O'} {t.text}
</li>
))}
</ul>
)
}
Liveblocks's strengths are operational simplicity and React-optimized DX. The weaknesses are pricing (seat-based and MAU-based) and lock-in to their proprietary CRDT (self-hosting fully is hard).
Chapter 10 · PartyKit — Collaboration on Cloudflare Durable Objects
PartyKit (founded by Sunil Pai in 2023, acquired by Cloudflare in 2024) takes a different route. Each room is a Durable Object that runs arbitrary code — Yjs, Automerge, or your own logic.
// PartyKit: server code (server.ts)
import type * as Party from 'partykit/server'
import { onConnect } from 'y-partykit'
export default class YjsServer implements Party.Server {
constructor(readonly room: Party.Room) {}
async onConnect(conn: Party.Connection) {
// y-partykit handles Yjs sync automatically
return onConnect(conn, this.room, {
persist: { mode: 'snapshot' },
})
}
}
PartyKit's advantage is running user code at the edge. Auth, permissions, persistence, and game logic can all be written per room. The downside is Cloudflare lock-in — although by 2026 that is arguably a benefit, since global edge distribution comes for free.
Chapter 11 · Self-Hosting — Hocuspocus + Yjs
If cost or lock-in is a concern, Hocuspocus is the answer. Built by the Tiptap team, it is a Yjs backend that runs on Node.js and persists to Postgres, SQLite, or Redis.
// Hocuspocus server
import { Server } from '@hocuspocus/server'
import { Database } from '@hocuspocus/extension-database'
const server = new Server({
port: 1234,
extensions: [
new Database({
fetch: async ({ documentName }) => {
// Load Yjs binary from DB
const row = await db.documents.findUnique({ where: { name: documentName } })
return row?.state ?? null
},
store: async ({ documentName, state }) => {
// Save to DB
await db.documents.upsert({
where: { name: documentName },
update: { state, updatedAt: new Date() },
create: { name: documentName, state },
})
},
}),
],
async onAuthenticate({ token, documentName }) {
// JWT verification, etc.
if (!verifyToken(token)) throw new Error('Unauthorized')
return { userId: extractUserId(token) }
},
})
server.listen()
You own the operations, and data sovereignty is yours. The trade-off: scaling, HA, and monitoring fall on you. Reach a Notion or Linear level and you will end up building custom infrastructure anyway.
Chapter 12 · ShareDB — Survivor of the OT Camp
ShareDB (formerly derby-share) is an OT-based self-hosted library. Widely used in pre-Yjs collaboration apps (around 2015) and still in active service in teams comfortable with OT.
- Represents documents as JSON and propagates JSON OT operations
- Persists to MongoDB or Postgres
- Text OT (
json0andjson1types) is battle-tested - Weakness: P2P and offline are less natural than with CRDTs
For new 2026 projects, the standard recommendation is CRDTs (Yjs/Automerge/Loro). But a healthy ShareDB system is not worth ripping out.
Chapter 13 · Replicache and Zero — A Different Answer to "Local-First Sync Engines"
Rocicorp's Replicache (2020-) and its successor Zero (2025-) are not CRDTs. They use a mutator-based sync + authoritative server + optimistic UI model.
- The client defines "mutators" (e.g.,
addTodo(text)). - The mutator runs locally and the UI updates immediately.
- The same mutator request is sent to the server, where it is re-run authoritatively.
- When the server result arrives, the local result is overwritten (rollback possible).
If CRDTs say "multiple truths eventually converge," Replicache says "the server is the final truth and the client makes an optimistic guess." Linear made this model famous.
Chapter 14 · Fluid Framework — Microsoft's Enterprise Answer
Fluid Framework powers Microsoft 365's realtime collaboration (Office Online, Loop). It is not strictly a CRDT but a sequence-based sync model of its own.
- Distributed Data Structures (DDS) like SharedString, SharedMap, and SharedTree
- Runs on Azure Fluid Relay
- An official SDK exists but external adoption is rare; effectively a Microsoft-internal engine
A new external app in 2026 is unlikely to pick Fluid, but if you build collaboration on top of Office or Microsoft Graph, it is worth knowing.
Chapter 15 · Whiteboard Collaboration — Tldraw and Excalidraw
tldraw (@steveruizok, @orta) is a whiteboard and diagram library, and they built their own multiplayer sync engine. Internally it resembles op-based Yjs, but it is optimized for the canvas data structure (a shape tree). The @tldraw/sync package syncs rooms via React + a WebSocket server, the reference implementation runs on Cloudflare Durable Objects, and per-shape Last-Writer-Wins (LWW) matches the semantics of drawing tools.
Excalidraw, by contrast, is the icon of hand-drawn whiteboards, and its collaboration rooms run on an encrypted Firebase Realtime Database. It does not use a formal CRDT, just LWW with client-side merges. Surprisingly reasonable: whiteboards are short sessions with a small number of concurrently edited shapes, and CRDT metadata overhead is not needed.
tldraw's lesson: CRDTs are designed differently for different data structures. Text CRDTs (Yjs Y.Text) and canvas CRDTs (tldraw) share a theoretical foundation but their implementation details differ entirely. Not every collaborative app needs a full CRDT.
Chapter 16 · Figma's Multiplayer Architecture
Figma built its own multiplayer engine in 2016-2017. It is a CRDT variant applying LWW per node and per property. The essentials:
- Document = tree of nodes; each node = a property bag
- Each property carries a
(timestamp, clientID)of its latest change - Conflict = larger timestamp wins (LWW); ties broken by larger clientID
- Tree structure changes (re-parenting, reordering) use a separate tree CRDT layer
Figma engineer Evan Wallace published this architecture in a 2019 blog post, and many collaboration apps adopted similar patterns. tldraw, in part, drew inspiration from it.
Chapter 17 · Google Docs and the History of OT
Google Docs began with the 2010 Writely acquisition and launched simultaneous editing later that year. The engine is Jupiter (Google's internal name) OT. Core ideas:
- Every edit is a (revision, operation) pair
- Clients send op to the server; the server returns transformed ops
- TP1 (Transformation Property 1):
op1 \* (op2 transformed against op1) == op2 \* (op1 transformed against op2)must hold
Writing OT transform functions correctly is notoriously hard. Google polished its code over more than a decade. The industry concluded the cost was too high for new apps and moved to CRDTs.
Chapter 18 · Presence / Awareness — "Who Is Looking at What, and Where?"
The other half of collaboration is presence. Who is online, where their cursor is, what they have selected, what color they appear as.
Yjs standardized this with y-protocols/awareness. It is an ephemeral state distinct from the CRDT body. Metadata includes:
clientID(shared with the Y.Doc)name,color,cursor,selection, and free-form JSON- Heartbeats to indicate liveness; automatic cleanup on disconnect
Liveblocks treats awareness as a first-class citizen and exposes useOthers() and useUpdateMyPresence(). PartyKit can implement it with its own broadcast primitives. Either way, presence should not be persisted — when the room closes, it goes away.
Chapter 19 · WebSocket vs WebRTC — Choosing the Transport
CRDT sync goes over one of two transports.
| Criterion | WebSocket | WebRTC (DataChannel) |
|---|---|---|
| Server required | Required | Only STUN/TURN for NAT traversal |
| Latency | Server round-trip | Direct P2P (can be lower) |
| Scaling | Server fans out | Mesh topology is N^2 |
| Persistence | Natural | Needs server mirror |
| Firewall | Usually passes | May be blocked without TURN |
| Standard libraries | y-websocket, y-partykit | y-webrtc |
The 2026 default recommendation: WebSocket as the primary, with WebRTC as a supplement when true P2P matters (security, offline mesh) or when you want to offload server traffic.
Chapter 20 · CRDT Library Comparison
| Library | Language | Strengths | Weaknesses | Headline Users |
|---|---|---|---|---|
| Yjs | JS/TS (+Yrs Rust port) | Maturity, ecosystem, text performance | Memory footprint a bit large | Tiptap, BlockNote, Lexical Yjs |
| Automerge | Rust/WASM | Strong JSON tree merging | Text performance below Yjs | PushPin, Trail Runner |
| Loro | Rust/WASM | Rich text, trees, time travel | Newer ecosystem | New apps |
| Yjs + Yrs | Rust port | Native Rust backend | Wire compatibility with Yjs only partial | Server-side Yjs |
| Diamond Types | Rust | Benchmark champion for single-doc text | No JSON | Benchmarks |
| sjs (Synchronized JS) | Experimental | TypeScript-friendly | New | Research |
Chapter 21 · SaaS vs Self-Hosting Comparison
| Criterion | Liveblocks | PartyKit | Hocuspocus (self) | Replicache | Fluid (MS) |
|---|---|---|---|---|---|
| CRDT family | Proprietary + Yjs adapter | Yjs/Automerge, freely | Yjs | Non-CRDT (optimistic) | DDS (non-CRDT) |
| Hosting | SaaS | Cloudflare edge | Self | Self or Rocicorp | Azure |
| Pricing | MAU-based | Usage-based | Infra only | Usage + SaaS | Azure rates |
| Self-host possible | Partial (Edge Storage option) | Hard (CF tied) | Fully | Replicache: yes | Hard |
| Data sovereignty | US / EU options | Global edge | Free choice | Free choice | Azure regions |
| Awareness first-class | Yes | DIY | Yes | N/A | Partial |
| Comments / Threads module | Yes | None | None | None | Loop separate |
Chapter 22 · Korea and Japan Cases — Notion, Kakao, Cybozu, Sansan
Korea's collaboration market is interesting. After Notion's Korean launch (2020), the share of Notion API plus self-built collaboration rose quickly (Notion uses a proprietary CRDT-like model, with little public documentation). Kakao Work and Kakao Talk messages are not CRDTs, but Kakao Work's boards and memo collaboration use OT (publicly stated), and Kakao Enterprise built its own engine during its "remote collaboration" push. Naver Line Works covers document, table, and calendar collaboration; integrated with Line in the Japanese market, it holds significant Japanese enterprise share. Toss / Toss Payments has publicly discussed using Yjs + Tiptap for internal collaboration tools (archives, documents).
Japan is interesting too. Cybozu kintone, a business-app builder, applies OT-based transforms when multiple users edit the same record concurrently; Cybozu is famous for its long OT expertise. Sansan, a business-card cloud, has presented CRDT applications in workflows where multiple users correct OCR results of card data concurrently. Rakuten RMS is the shop-owner collaboration tool for Rakuten malls, with its own internal sync engine. Notion's Japan team runs the same engine as English-speaking markets, but a Japanese PM presentation revealed patches for collaboration conflicts with Japanese IME (in-progress composed characters).
Chapter 23 · CRDT Adoption Decision Matrix
When are CRDTs the answer, and when are they not?
| Situation | Recommendation |
|---|---|
| Concurrent text editing (an editor) | Yjs + Tiptap/BlockNote/Lexical-Yjs |
| Whiteboard / canvas | tldraw sync or Yjs Y.Map |
| Forms / tables (per cell) | Yjs Y.Map with per-cell keys or ShareDB |
| Game state (time-deterministic) | Lock-step or authoritative server, not CRDTs |
| Chat messages | Not needed, append-only log |
| Notifications / realtime counters | CRDT G-Counter (academically), or Redis |
| Offline-first (local-first) | Automerge, Loro, Yjs IndexedDB persistence |
| Server authority matters (payments, inventory) | Replicache or classic transactions |
Chapter 24 · Persistence Strategy — IndexedDB, Postgres, Binary Snapshots
CRDTs need to be stored somewhere.
- Client-side: Yjs's
y-indexeddb, Automerge's IndexedDB adapter. After offline edits, sync resumes automatically on reconnect. - Server-side (Yjs binary): Yjs's
encodeStateAsUpdateproduces a full-state binary. Postgresbyteacolumns, S3 objects, Redis — anywhere works. - Server-side (delta log): accumulate every update in a log and periodically compact to a snapshot. Useful for large documents.
- Hybrid: snapshot + post-snapshot deltas. On load, apply snapshot first and then deltas.
The hidden trap is GC. Tombstones seen by every client can be deleted, but knowing what "every client has seen" is hard. Yjs is conservative and lets operators run explicit compaction periodically.
Chapter 25 · Security, Permissions, End-to-End Encryption
Security models for collaboration SaaS fall into three categories.
- Server sees plaintext (most common): the standard Liveblocks and Hocuspocus model. Permission checks, search, and comment moderation are easy.
- End-to-end encryption (E2EE): clients encrypt Yjs updates before sending. The server only sees binary blobs and knows no semantics. Excalidraw rooms use a similar model. Weakness: search, server-side comment analysis, and server-side merging are impossible.
- Partial encryption: content is E2EE, metadata (room membership, presence) is plaintext. A middle ground.
Permissions typically combine per-room tokens with per-operation checks: who can enter (JWT) and what they can do once inside (onAuthenticate + beforeHandleMessage in Hocuspocus).
Chapter 26 · Epilogue — Looking at 2027
CRDTs have left the academic realm and entered operations. Three changes to expect by 2027:
- Mobile and native become first-class citizens. Yjs Swift ports, Automerge Kotlin, and Loro Flutter bindings stabilize, and mobile collaboration apps explode. Local-first becomes genuinely viable.
- AI agents become collaboration participants. Cursor and Claude Code become another "user" editing the same document in real time, and awareness distinguishes humans from AI.
- Standardization begins. CRDT wire formats enter discussion at IETF / W3C levels. Yjs, Automerge, and Loro are not yet wire-compatible, but the contour of a "standard text CRDT" is starting to appear.
The biggest lesson is simple. The world without a Save button has already arrived. What is left is deciding what to build on top.
References
- Shapiro, Marc et al. "Conflict-free Replicated Data Types." INRIA Research Report (2011). https://hal.inria.fr/inria-00609399v1
- Kleppmann, Martin and Beresford, Alastair R. "A Conflict-Free Replicated JSON Datatype." IEEE TPDS (2017). https://arxiv.org/abs/1608.03960
- Kleppmann, Martin et al. "Local-first software: You own your data, in spite of the cloud." Onward! 2019, Ink & Switch. https://www.inkandswitch.com/local-first/
- Kleppmann, Martin. "Designing Data-Intensive Applications." O'Reilly (2017) — chapter on replication and CRDTs.
- Yjs documentation. https://yjs.dev and https://docs.yjs.dev
- Automerge documentation. https://automerge.org and https://github.com/automerge/automerge
- Loro documentation. https://loro.dev and https://github.com/loro-dev/loro
- Liveblocks documentation. https://liveblocks.io/docs
- PartyKit documentation. https://docs.partykit.io
- Hocuspocus documentation. https://tiptap.dev/docs/hocuspocus
- ShareDB. https://github.com/share/sharedb
- Replicache and Zero. https://replicache.dev and https://zero.rocicorp.dev
- Fluid Framework. https://fluidframework.com
- tldraw sync. https://tldraw.dev/docs/sync
- Excalidraw collaboration. https://github.com/excalidraw/excalidraw
- Evan Wallace, "How Figma's multiplayer technology works." Figma blog (2019). https://www.figma.com/blog/how-figmas-multiplayer-technology-works/
- Diamond Types — Seph Gentle. https://github.com/josephg/diamond-types
- Kleppmann, Martin et al. "Moving Elements in List CRDTs." PaPoC 2020. https://martin.kleppmann.com/papers/list-move-papoc20.pdf
- Yjs internals: Kevin Jahns, "Are CRDTs suitable for shared editing?" https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/