Choosing a CRDT Strategy for Real-Time Collaborative Editing: Lessons from Canva's Implementation
A decision guide for engineers evaluating CRDT-based real-time collaboration: compare sequence vs. hierarchical CRDTs, understand Canva's hybrid approach with custom precedence rules, and run a concrete latency benchmark to validate sub-150 ms edit propagation.
21 Jul 2026, 16:16 UTC

The Decision: Selecting a Conflict-Free Replicated Data Type for Multi-User Canvas Editing
When building real-time collaboration into a design tool like Canva, the core engineering decision is how to merge concurrent edits—text changes, object moves, layer reordering—without a central server becoming a bottleneck or single point of failure. Canva chose a CRDT-based sync layer that operates client-side, delivering sub-second consistency across browsers while keeping the existing React rendering pipeline intact. This guide compares the viable CRDT approaches for canvas-style editors, outlines the trade-offs Canva accepted, and shows how to validate a similar implementation in your own stack.
Constraints That Shape the Choice
- Offline-first requirement: Users must edit designs on planes, in subways, or during flaky conference Wi-Fi. The solution must persist version vectors locally and reconcile cleanly on reconnect.
- Sub-150 ms edit propagation: Designers expect keystroke-to-screen latency comparable to single-user mode. Any coordination protocol adding round-trips to a central sequencer fails this bar.
- Schema-rich objects: A design isn't plain text. It contains nested frames, vector paths, images with transform matrices, and text runs with rich formatting. The CRDT must represent hierarchical, typed data—not just a linear sequence.
- Browser quota limits: IndexedDB storage grows with edit history. Large designs (100+ artboards, thousands of layers) can exceed the default 50–100 MB quota without pruning.
Comparing CRDT Options for Canvas Editors
| Approach | Data Model Fit | Offline Support | Merge Complexity | Bandwidth (per user) | Known Production Users |
|---|---|---|---|---|---|
| RGA / YATA (sequence CRDTs) | Linear text only; poor for nested objects | Native via version vectors | Low (character-level) | ~5–15 KB/s | Figma (text), Atom Teletype |
| Yjs (Y.Doc + Y.Map + Y.Array) | Hierarchical, typed collections | IndexedDB persistence built-in | Moderate (custom types needed) | ~10–25 KB/s | Many OSS apps, some SaaS |
| Automerge (JSON-like document) | Arbitrary JSON trees | Local-first sync protocol | High (full doc merge) | ~15–40 KB/s | Some note-taking apps |
| Custom hybrid (Canva) | Domain-specific: layers, transforms, text runs | Version vectors in IndexedDB | Custom precedence rules | <20 KB/s | Canva |
Canva's hybrid approach wraps a minimal sequence CRDT for text runs inside a custom object CRDT for layer hierarchy and transform matrices. This avoids the overhead of a general-purpose JSON CRDT while still supporting the rich object model a design canvas demands.
Trade-Offs Canva Accepted
1. Custom Precedence Over Pure Commutativity
Standard CRDTs guarantee convergence without coordination by making all operations commutative. Canva introduced a precedence rule that favors the most recent user action (by logical timestamp) when two users simultaneously drag the same layer to different positions. This breaks strict commutativity but matches designer expectations: the last intentional move wins. The cost is a rare divergence scenario—if two users edit offline and reconnect, the merge may produce a state neither intended, requiring manual reconciliation.
2. IndexedDB Growth Without Automatic Pruning
Version vectors and operation logs accumulate in IndexedDB. Canva's telemetry shows a 50-artboard design with 200 edits/day per user reaches ~80 MB after 30 days. They mitigate this with a background compaction job that snapshots the current state and truncates history older than 14 days, but the job runs only when the tab is active and the browser grants quota. Engineers adopting this pattern must implement their own quota watchdog.
3. Schema Validation as a Security Boundary
Malicious or buggy clients could send CRDT payloads that, when merged, corrupt the design (e.g., negative layer indices, cyclic parent references). Canva validates every incoming operation against a JSON Schema before applying it to the local CRDT. This adds ~2 ms per operation but prevents a class of supply-chain attacks where a compromised extension injects bad merges.
Concrete Validation: Benchmarking Edit Propagation Latency
To verify a CRDT implementation meets the sub-150 ms target, run a controlled benchmark with 5+ concurrent browser sessions. The following steps use Canva's open-source CRDT library (MIT licensed, available at github.com/canva/crdt-sync) as a reference, but the method applies to any client-side sync layer.
Prerequisites
- A test design with 20 layers (mixed text, vectors, images)
- 5+ browser instances (Chrome 118+ recommended) on the same LAN
- Network throttling disabled; measure raw WebSocket latency separately
Step 1: Instrument the Client
Add a lightweight telemetry shim to the CRDT adapter that timestamps each local edit and each remote merge application:
// client/crdt-telemetry.js (inject before Canva's adapter initializes)
const telemetry = [];
export function markLocalEdit(opId) {
telemetry.push({ type: 'local', opId, t: performance.now() });
}
export function markRemoteMerge(opId, originClientId) {
const local = telemetry.find(e => e.opId === opId && e.type === 'local');
if (local) {
telemetry.push({ type: 'latency', opId, delta: performance.now() - local.t, originClientId });
}
}
export function dump() { return telemetry; }
Run this in the browser console of each participant tab after the design loads. Requires no elevated permissions—just console access.
Step 2: Generate Concurrent Edits
Use a scripted Puppeteer or Playwright scenario to simulate realistic actions across the 5 clients:
// benchmark/edit-scenario.js
const { chromium } = require('playwright');
async function run() {
const browsers = await Promise.all(
Array(5).fill().map(() => chromium.launch({ headless: false }))
);
const pages = await Promise.all(browsers.map(b => b.newPage()));
// Navigate all to the test design
await Promise.all(pages.map(p => p.goto('http://localhost:3000/design/test-design')));
// Inject telemetry
await Promise.all(pages.map(p => p.addScriptTag({ path: 'client/crdt-telemetry.js' })));
// Simultaneous edits: each client moves a different layer, then all edit same text box
await Promise.all(pages.map((p, i) => p.evaluate((idx) => {
window.markLocalEdit(`move-layer-${idx}`);
canvasAPI.moveLayer(`layer-${idx}`, { x: 100 + idx * 10, y: 100 });
}, i)));
await new Promise(r => setTimeout(r, 2000)); // allow propagation
// Collect results
const results = await Promise.all(pages.map(p => p.evaluate(() => window.dump())));
console.log(JSON.stringify(results.flat(), null, 2));
await Promise.all(browsers.map(b => b.close()));
}
run();
Run from a developer machine with Node 18+ and Playwright installed (npm i playwright). No server-side permissions needed; the test hits your local dev server.
Step 3: Analyze Latency Distribution
Filter the collected telemetry for type: 'latency' entries. Compute p50, p95, p99. Canva's published figure is <150 ms p99 for 10-person sessions. If your p99 exceeds 200 ms, investigate:
- WebSocket frame size (large ops may need chunking)
- CRDT merge computation on the main thread (consider Web Worker offload)
- IndexedDB write latency during version vector updates
Expected Checks and Risks
| Check | Pass Criteria | Risk If Failed |
|---|---|---|
| p99 latency < 150 ms | All 5 clients report delta < 150 ms | Users perceive lag; concurrent edits feel "jumpy" |
| Bandwidth < 20 KB/s per client | Network tab shows < 1.2 MB over 60 s per tab | Mobile users hit data caps; battery drain |
| Zero schema validation errors | Console shows no "CRDT payload invalid" warnings | Corrupt design state; potential security issue |
| IndexedDB quota < 80% after 1 hr | Application tab > Storage shows < 40 MB used | Browser evicts data; offline edits lost |
Limitations and When to Re-evaluate
- Offline divergence: The custom precedence rule means two users editing the same layer offline for hours can produce a merged state neither expects. Canva surfaces a "merge conflict" banner requiring manual resolution. If your domain cannot tolerate any manual step, consider a pure commutative CRDT (e.g., Yjs with custom types) and accept higher bandwidth.
- Large design scaling: The benchmark above uses 20 layers. At 500+ layers, merge computation grows non-linearly. Canva mitigates this by partitioning the CRDT per artboard, but that adds routing complexity. Test with your maximum design size before committing.
- Browser quota variance: Safari's IndexedDB quota is stricter (~50 MB) and less predictable. The compaction job must run more aggressively on iOS. Verify on actual devices, not just Chrome DevTools device emulation.
Practical Verification Checklist
- Clone the CRDT library and run its internal test suite:
npm test(expects 100% pass on Node 18+). - Execute the benchmark scenario above against your integration. Record p99 latency.
- Open Chrome DevTools > Application > IndexedDB, inspect the
crdt-version-vectorsstore after 100 edits. Confirm entry count matches expected version vector size (one per client per object). - Simulate offline: disconnect network, make 20 edits, reconnect. Verify the merge completes without data loss and the conflict banner appears only when precedence rule triggers.
- Run the schema validator against a corpus of 10,000 generated operations (fuzz test). Zero crashes or unhandled exceptions required.
If all five checks pass, the CRDT layer is ready for staging. The same checklist applies whether you adopt Canva's open-source library, Yjs, or a custom implementation—the constraints and validation method remain identical.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.