Real-time multiplayer canvas with a hand-rolled CRDT. Open the same room in two tabs and watch shapes, freehand ink, images and live cursors merge conflict-free, with no server-side lock. Go offline, keep drawing, reconnect β the two diverged boards snap back to the same pixels. And because every edit is an immutable op, you can scrub the board's whole history like a video.
messaging is delivery. weave is convergence. The hard part isn't the WebSocket β it's the replicated data type underneath that makes concurrent edits provably agree, and the event-sourced op-log that turns "every change ever" into time-travel.
βΆ Try it right now β jinwovo.github.io/weave
No install, no server, nothing to sign up for. The convergence playground puts two real
replicas of this CRDT in one page, joined only by a simulated hostile network. Draw on both
sides, crank up latency, drop and duplicate packets, cut the wire β the state fingerprints
diverge in front of you and snap back to β‘ the moment delivery resumes. Hit βΆ run the story
for a 20-second guided tour:
The full multiplayer app (below) is the same CRDT with a WebSocket and an op-log behind it:
Every distinctive feature falls out of the architecture, not bolted-on UI:
| Feature | Powered by |
|---|---|
| π’ Multiplayer, conflict-free β concurrent edits never clobber | the CRDT (LWW-Map over HLC) |
| βοΈ Collaborative text β two people type in one note, character by character | a sequence CRDT (Fugue, TPDS 2025), proven by a fault-injection sim |
| βΆ Undo / redo β Ctrl+Z works with live collaboration, not against it | inverse ops re-authored with a fresh stamp (LWW always wins) |
| π Live presence β see peers' cursors and the shape they're drawing before they commit | ephemeral draft preview fanned out on the cursor channel |
| π Offline-resilient β keep editing while disconnected, reconnect β auto-merge | optimistic local CRDT + op outbox + reconcile |
| π§ͺ Convergence playground β break the network yourself, watch replicas reconverge | two in-page replicas over a simulated adversarial network |
| π§΅ Interleaving lab β the same partitioned keystrokes through RGA and Fugue, live | RGA shuffles the authors together; Fugue keeps each block intact |
| π Time-travel β scrub / replay the board's entire history | the append-only op-log (event sourcing) |
| π Hourly board + archive β fresh canvas each hour, past hours browsable read-only | op-log bucketed by room base@<hour> |
| πΌ Images (paste / drop), βοΈ ink, β shapes, ‑ resize, π§½ erase | all just shapes in the same convergent document |
Multiplayer editing (Figma / Excalidraw / tldraw) needs shared state that:
- lets many people edit the same object simultaneously without a central lock,
- survives offline edits and reconnects without losing work, and
- converges β every replica that has seen the same edits shows the identical board.
A naΓ―ve "broadcast every change" approach silently diverges the moment two people touch the same thing at once. The correct tool is a CRDT (Conflict-free Replicated Data Type), and the correctness has to be proven, not eyeballed.
A board is a map shapeId β ShapeState. Every property of a shape is an independent
Last-Writer-Wins register stamped with a Hybrid Logical Clock timestamp + actor id
(globally unique, totally ordered). merge is the field-wise β key-wise least upper bound,
which is commutative, associative and idempotent β a join-semilattice, the sufficient condition
for convergence. Operations are applied by merging the delta they imply, so they are immune to
reordering and duplication. The server is a dumb relay + persistence layer, never the source of
truth.
The same op-log is also an event store: replaying any prefix of it reconstructs the board at
that moment β which is exactly what powers time-travel. And a room is bucketed as
base@<hoursSinceEpoch>, so the live board resets every hour while each past hour stays
preserved under its own room id and remains browsable.
See docs/adr/0001-lww-map-crdt-over-hlc.md for the
decision and the alternatives (OT, RGA/YATA, OR-Set, off-the-shelf Yjs) that were rejected.
LWW is the right call for geometry and style, but text has to merge character-by-character β so text content is a sequence CRDT of its own. It started as RGA (ADR-0002), which converges but can interleave two users' concurrent runs of text β a limitation that ADR documented on day one. It is now Fugue (Weidner & Kleppmann, βThe Art of the Fugueβ, IEEE TPDS 2025 Β· ADR-0005): each character is a node in a tree β hung to the right of its predecessor, or to the left of the element it displaces β so a run of typing forms a chain that concurrent runs cannot thread through. Deletes are tombstones, ops buffer until their dependency arrives (delivery is at-least-once and unordered), and every character carries a unique HLC id. It backs sticky / text shape bodies live β double-click a note and two people can type into it at once β and the interleaving lab lets you watch RGA fail where Fugue holds.
Undo/redo falls out of the same model. There is no "previous document" to rewind to β the log is
append-only and shared β so each local edit records an inverse op (move β move-back, create β
delete, delete β re-create) that undo re-authors with a fresh timestamp. Because the board is
last-writer-wins, a fresh stamp always wins, so "undo" is just "author the reverse edit now" β which
commutes with everyone else's concurrent edits: undo my move after you recolor the same shape and
your color stays. Per-user stack, zero server involvement. See
docs/adr/0003-inverse-op-undo.md.
Live presence is the same ephemeral channel as cursors. While you drag out a shape or ink a stroke, the in-progress draft is broadcast (throttled, never persisted) so collaborators watch it form β a translucent fill with a dashed outline in your colour β and disappears the instant you commit or cancel. So you don't just see where someone is, you see what they're about to draw.
flowchart LR
subgraph CA["client A Β· browser"]
DA["CanvasDoc + HlcClock<br/>optimistic Β· offline outbox"]
end
subgraph CB["client B Β· browser"]
DB["CanvasDoc + HlcClock<br/>optimistic Β· offline outbox"]
end
subgraph APP["weave app Β· Spring Boot 4.1"]
WS["WebSocket relay"]
HTTP["HTTP API<br/>/history Β· /epochs"]
end
R[("Redis Pub/Sub<br/>ops Β· cursors fan-out")]
PG[("PostgreSQL<br/>append-only op-log")]
DA <-->|ops| WS
DB <-->|ops| WS
WS -->|persist| PG
WS -->|publish| R
R -->|fan-out| WS
HTTP -->|replay| PG
DA -. "time-travel Β· reconnect" .-> HTTP
DB -. "time-travel Β· reconnect" .-> HTTP
A client applies each op to its local CanvasDoc immediately (optimistic), then sends it over the
WebSocket. The relay persists it to the append-only op-log and publishes it to Redis; the Redis
echo is the single broadcast path, so every instance β the origin included β relays an op to its own
sockets. A late joiner gets a snapshot; time-travel and the hourly archive read that same op-log
over HTTP. The server stores and fans out β it never decides the state; the CRDT does.
Cold-start recovery is bounded, not lifetime-long. A background sweeper materialises each busy
room's fold into a canvas_snapshot row (every register with its HLC timestamp, tombstones included β
lossless at the CRDT level), watermarked by an insertion-order seq. Rebuilding a room then folds
snapshot + tail instead of the whole log β O(tail), tail β€ threshold + one sweep window. It's
exact, not approximate: the document fold is a join-semilattice, so folding any subset then the rest β
any order, any overlap β rebuilds the identical document (property-tested). The watermark only advances
past a grace window so a seq handed out before its transaction commits can never be skipped, and
/history still reads the full log β snapshots bound recovery, they don't rewrite history.
(ADR-0004)
The op-log + CRDT make a dropped connection a non-event: keep editing, reconnect, and the diverged boards merge by themselves.
sequenceDiagram
participant A as client A
participant S as weave app
participant L as op-log (Postgres)
participant B as client B
Note over A: π offline
A->>A: edit locally (optimistic) + queue in outbox
B->>S: edit (online)
S->>L: append op
Note over A: π reconnect
A->>S: flush outbox
S->>L: append (idempotent Β· room, actor, hlc)
A->>S: GET /history
S-->>A: full op-log
A->>A: merge β LWW by HLC
Note over A,B: converged β identical board
The convergence guarantee is enforced by property-based tests (jqwik), not anecdotes:
- order- & duplication-independence β any delivery order of an op-log yields the same doc;
- partition β gossip β convergence β replicas that each saw only a slice of the edits all reconcile to the identical board after exchanging state;
- semilattice laws β
mergeis commutative, associative and idempotent; - HLC algorithm β monotonic ticks, counter reset on physical progress, receive-side advance;
- sequence CRDTs (Fugue + RGA) β concurrently-authored text ops, delivered in every order with duplicates, converge to one string (character-level, not LWW);
- non-interleaving β the property RGA cannot offer: concurrent runs typed at the same position (forward and prepending) stay contiguous under Fugue, across 300 randomized adversarial schedules each β plus the paper's canonical YATA-anomaly vector ("axb" is forbidden; RGA produces exactly it) and a differential test that runs one editing session through both CRDTs;
- deterministic fault-injection simulation β a Jepsen-lite / DST harness: 3β4 replicas under an adversarial network (delay, reorder, drop-and-redeliver, duplicate) converge across 400 seeds, every run reproducible from its seed;
- the snapshot contract β folding any prefix into a materialised snapshot, then the tail β shuffled, with part of the prefix re-delivered on top β rebuilds the exact full document.
./gradlew :crdt-core:test # 27 testsThe sync server is proven end-to-end against real Postgres + Redis (Testcontainers): ops
authored by one WebSocket client fan out to another, land in the durable op-log exactly once (a
duplicate is absorbed by the (room, actor, hlc) constraint), and a late-joining client converges
to the identical board from the snapshot alone β purely by replaying the op-log through the CRDT.
SnapshotIntegrationTest additionally proves the sweeper snapshots a busy room, a cold start folds
only the tail beyond the watermark, and the bounded rebuild β including a tombstone, a
post-snapshot resurrection, and an idempotent double-refresh β equals the full-log fold exactly.
./gradlew :app:test # 12 tests (Testcontainers PG + Redis)Multi-instance convergence is proven by MultiInstanceConvergenceTest, which boots two app
contexts sharing one Postgres + Redis and asserts an op authored on instance 1 reaches a client on
instance 2 β only possible via the Redis fan-out. A k6 load test (load/convergence.js)
across a 2-instance cluster measured op fan-out latency at median 16 ms Β· p90 70 ms for ~100 ops/s
(77k messages fanned out, p99 β 550 ms tail on a cold local run).
k6 run load/convergence.js # against two instances on :8103 and :8104The property tests prove convergence under an adversarial network; the playground is that exact fault model made interactive, so you can watch the proof instead of taking the README's word for it:
- Two real replicas β each pane owns its own
CanvasDoc, HLC clock and per-shape Fugue text, running the same TypeScript CRDT as the live client, and they exchange the same wire format the WebSocket protocol uses. The only thing simulated is the network. - An honest fault model β sliders for latency (Β±45% jitter, so packets genuinely reorder), loss and duplication, plus a one-click partition. Ops are reliable: a lost packet visibly dies mid-wire (β) and is retransmitted until it lands β at-least-once, exactly like the real client's outbox against the server's idempotent op-log β and duplicates are absorbed because merge is idempotent. Cursors and draft previews are ephemeral: lost is lost, like the real cursor channel.
- Falsifiable convergence β each pane shows a live fingerprint of its full CRDT state
(every register with its HLC stamp, tombstones included, every text node). The badge compares
them:
βwhile partitioned,β‘once every op has been delivered. If the fingerprints ever differed with nothing in flight, the badge has a dedicated "this should be impossible" state β it has never rendered.
The README GIF above is recorded by scripts/capture-playground.mjs,
which drives the βΆ run the story tour against the exact static build GitHub Pages serves and
fails unless the badge passes through the partition act and returns to CONVERGED with equal
fingerprints β so the demo can't silently rot.
Below the playground sits a second experiment: the same partitioned editing session β two users each prepending items to one shared todo list β run through both of weave's sequence CRDTs at once. Every keystroke gets the identical HLC stamp in both engines; only the placement rule differs. RGA (timestamp-ordered, weave's original text type) converges to a fully shuffled B/A/B/A merge; Fugue keeps each author's items contiguous. Convergence was never the question β what the merged text reads like is.
scripts/capture-lab.mjs records this GIF and fails unless RGA
interleaves (β₯3 author blocks), Fugue holds at exactly 2, and both engines' replica pairs end
converged β the anomaly and the fix are both asserted, not narrated.
- crdt-core β pure Java 21, zero production dependencies; tests on
jqwik+ JUnit 5. - app β Spring Boot 4.1: WebSocket relay, idempotent op-log on PostgreSQL (Flyway), Redis pub/sub
fan-out, snapshot replay, and HTTP
β¦/history+β¦/epochsendpoints (time-travel & archive). - web β Next.js 15 + Canvas client: shapes, freehand pen, sticky text, images (paste/drop),
resize, eraser, undo/redo (Ctrl+Z), live cursors, presence. A faithful TypeScript port of the CRDT gives
local-first optimistic edits that converge with the server and every client β including offline
edits that flush + reconcile on reconnect. Plus time-travel replay, the hourly archive, and
the playground (
/playground) β the same client CRDT over an in-page simulated network, statically exported to GitHub Pages (no backend at all). - observability β Micrometer metrics on
/actuator/prometheus, scraped by Prometheus into a provisioned Grafana dashboard: active sessions/rooms, op ingest rate + persist latency (p50/p95/p99 via histogram buckets), HTTP p95 per route, JVM heap, and the snapshot row β snapshots written, cold-start tail size, refresh/replay latency.
Local ports (per workspace registry): app 8103, PostgreSQL 5437, Redis 6383,
front 3009, Prometheus 9099, Grafana 3011. Container prefix weave-.
| Phase | Scope | Status |
|---|---|---|
| P0 | Pure-Java CRDT core (LWW-Map over HLC) + property-based convergence proof | β done |
| P1 | Spring Boot 4.1 sync server: WebSocket relay, idempotent op-log, Redis fan-out, snapshot replay | β done |
| P2 | Next.js + Canvas client: shapes, pen, sticky text, images, resize, eraser, live cursors, presence | β done |
| P3 | Distinctive layer: time-travel replay Β· offline β reconnect reconvergence Β· hourly board + archive | β done |
| P4 | Multi-instance convergence (two app instances share Postgres + Redis) + k6 fan-out-latency load test | β done |
| P5 | Playwright two-client demo GIF + product polish (PNG export, copy-link) | β done |
| P6 | Sequence CRDT (RGA) collaborative text + deterministic fault-injection sim Β· Prometheus + Grafana observability | β done |
| P7 | Undo / redo via inverse ops β per-user, concurrency-safe, zero server change | β done |
| P8 | Snapshot-accelerated replay β lossless CRDT snapshots + seq watermark + grace window; cold start folds O(tail), proven exact by property test | β done |
| P9 | Convergence playground β two in-page replicas over a simulated adversarial network (latency/loss/dup/partition), state fingerprints, scripted tour; deployed server-less to GitHub Pages | β done |
| P10 | Fugue text CRDT (Weidner & Kleppmann, IEEE TPDS 2025) replaces RGA β non-interleaving concurrent text, proven by new property tests + 400-seed DST Β· interleaving lab in the playground runs RGA vs Fugue on identical keystrokes | β done |
Zero-install: the playground runs the CRDT entirely in your browser. Everything below is for the full multiplayer stack.
On Windows, one command brings up the whole stack (Docker Desktop β containers β app β web) and opens the board β it skips whatever is already running:
powershell -ExecutionPolicy Bypass -File scripts/dev-up.ps1Or piece by piece:
git clone https://github.com/jinwovo/weave && cd weave
./gradlew :crdt-core:test # P0 convergence proofs (no Docker needed)
docker compose up -d # weave-postgres (5437) + weave-redis (6383)
./gradlew :app:test # sync-server proof (Testcontainers spins its own PG + Redis)
./gradlew :app:bootRun # run the sync server on http://localhost:8103 (WS: /ws?room=&actor=)
cd web && npm install && npm run dev # canvas client on http://localhost:3009
# open two tabs at http://localhost:3009/?room=demo β draw, paste an image, go offline, hit π / πThe client tells you when the sync server isn't reachable (drawing still works locally β live cursors, history and the archive are what need it).







