A pure-Go implementation of the Yjs CRDT for embedding collaborative documents into Go services.
Use the CRDT and sync/awareness primitives directly, or combine them with the optional backend contracts, registry, hub, and adapter conformance suites. Your application owns transport, authentication, storage, and deployment.
Supports Yjs V1/V2 updates, with compatibility checked against the JavaScript reference implementation.
go get github.com/antst/go-yjs
Requires Go 1.26+. Zero runtime dependencies — go.sum is empty.
Used by Alkemio's collaboration service for collaborative memos and whiteboards, integrated with Alkemio's own transport, authentication, and persistence.
| Package | What it is |
|---|---|
crdt |
Documents, shared types, update encoding, transactions, snapshots, undo |
protocol |
Sync and awareness message framing |
backend |
Neutral identifiers shared by the ports — no CRDT values, no wire frames |
backend/persistence |
Port you implement: SQL, files, object store, your choice — two profiles |
backend/memory |
Document registry — working in-process default, replaceable |
backend/hub |
Fan-out — working in-process default, replaceable |
backend/cluster |
Optional. Multi-node document ownership, typically Redis |
backend/conformance |
Public suites you run against your own implementations |
The repository root is a documentation page and exports nothing.
Two replicas edit concurrently and converge. This is Example_convergence in crdt/example_test.go — every snippet below is a compiled, executed example, not prose.
first := crdt.NewDoc("notes", crdt.WithClientID(1))
second := crdt.NewDoc("notes", crdt.WithClientID(2))
first.GetText("body").Insert(0, "hello", crdt.Object{})
second.GetText("body").Insert(0, "world", crdt.Object{})
// Each side ships what the other has not seen.
firstUpdate, _ := crdt.EncodeStateAsUpdate(first, crdt.EncodeStateVector(second))
secondUpdate, _ := crdt.EncodeStateAsUpdate(second, crdt.EncodeStateVector(first))
crdt.ApplyUpdate(first, secondUpdate, nil)
crdt.ApplyUpdate(second, firstUpdate, nil)
first.GetText("body").ToString() // "helloworld", on both replicasBoth inserted at position 0 with no knowledge of each other. The tie breaks deterministically by client ID — the same way the JavaScript implementation breaks it, which is checked on every push rather than assumed.
The example pins client IDs so its output is reproducible. Ordinarily a document is just crdt.NewDoc("room-1") — everything optional is a DocOption, so each departure from the defaults is named where you make it: WithGC(false) to keep deleted content addressable for snapshots, plus WithClientID, WithMeta, WithAutoLoad and WithReadCache.
Available types: Y.Doc, Y.Text, Y.Array, Y.Map, Y.XmlFragment, Y.XmlElement, Y.XmlText, with subdocuments, snapshots, relative positions, an undo manager, and garbage collection.
Start with the executable single-process integration example or checkpoint-storage example. Alkemio's collaboration service shows a larger application integration.
A single process serving Yjs needs a transport adapter and somewhere to put bytes. Everything else has a working default.
-
Transport adapter — yours. WebSocket, SSE, gRPC, whatever your service already speaks. It is deliberately not in this module: transport belongs to your service, and a CRDT library that owns your connection lifecycle is one you have to fight.
-
Persistence — yours. Pick the profile that matches your medium:
persistence.Store— an append log. You append update bytes and load them back;Compactis optional and additive. Natural over SQL or any medium that can cheaply hold a growing history.persistence.CheckpointStore— one rewritable blob per document. Every save replaces the current state. Natural over object storage, or a single row or file.
Neither is a degraded version of the other. If your medium cannot cheaply append, the checkpoint profile is the correct answer rather than a workaround.
-
Registry and hub — shipped. Without defaults, every single-process service would first write a document registry and an in-process fan-out map: busywork with one correct answer, where each implementer gets the eviction and teardown races wrong differently.
-
Cluster — optional. A single process is a first-class configuration, not a degraded one. Persistence takes the cluster fence as optional, and
Fence(0)means "not clustered" rather than "unprotected".
OnUpdate gives you the exact bytes to persist and broadcast, plus the origin of the transaction that produced them.
doc.OnUpdate(func(update []byte, origin any) {
// update: the bytes to append to your store and forward to other clients
// origin: whoever caused this, so you do not echo it back to them
})The origin is what stops an echo loop. Apply a remote client's update with that client as the origin, and your handler can tell "someone else's edit, forward it" from "the edit I just applied on their behalf".
There is a typed subscription rather than a generic observer because this is the seam every server hangs off, and a ...interface{} callback makes every consumer open with a type assertion that fails silently when it is wrong.
Applying a remote update and appending it to your store cannot be one atomic operation. A transport adapter must choose which failure it accepts:
- Apply, then append. Invalid update bytes never enter durable history. If append fails after a successful apply, the live document is ahead of storage.
- Append, then apply. A storage failure cannot leave the live document ahead. If semantic application then fails, the stored update can poison every later replay.
Inspecting the frame with protocol.InspectMessage is allocation-free and validates its framing, but only applying the update validates all CRDT semantics. Whichever order you choose, publish to the hub only after the append crosses its durability boundary.
For the apply-first failure, call Registry.Invalidate. It poisons the current generation before waiting, closes every outstanding Handle.Done() channel, and sends concurrent acquisitions to a freshly loaded document. Session loops stop serving when Done closes and release their handles; invalidation destroys the stale document after the last release. Context cancellation bounds the wait but never makes the poisoned generation current again.
// Client announces what it has.
step1 := protocol.EncodeSyncStep1(client)
// Server answers with only the difference.
var reply bytes.Buffer
protocol.NewSyncHandler(server).HandleMessage(step1, &reply)
// Client applies it.
protocol.NewSyncHandler(client).HandleMessage(reply.Bytes(), &unused)SyncHandler owns the framing. Your transport adapter only moves byte slices between the two sides.
backend/conformance ships importable suites for each port. Run them against your implementation:
func TestMyPostgresStore(t *testing.T) {
newStore := func() persistence.Store { return NewPostgresStore(db) }
conformance.Persistence(t, newStore) // every append-log store
conformance.PersistenceCompaction(t, ...) // only if you implement Compact
conformance.PersistenceFencing(t, newStore) // only if you report a fenced mode
conformance.PersistenceDeletion(t, ...) // only if you implement Deleter
}The checkpoint profile has the matching set — CheckpointPersistence, CheckpointPersistenceFencing, CheckpointPersistenceDeletion and CheckpointPersistenceDeletionFencing — plus PersistenceFenceUpgrade for reading unfenced history through a fenced store. conformance.Memory, conformance.Hub and conformance.Cluster cover the other ports.
Nothing here is optional-by-omission: a store that declares a fence mode is held to it, and concurrency is not a separate suite you can skip. Every method may be called concurrently, so the concurrent rules run inside the suites above — concurrent appends keeping distinct increasing revisions, appends racing a compaction surviving it, fence authority decided by a single order, and a checkpoint never loading one save's update beside another save's state vector.
A checkpoint stores a Yjs update, and SaveCheckpointRequest.Encoding says which codec produced it — EncodingV1 or EncodingV2. It is required, and LoadCheckpoint must return what was saved.
This is not bookkeeping. A V1 state-vector decoder applied to V2 update bytes does not fail: it returns no error and a vector describing zero clients. A store that derives the codec from the bytes therefore cannot tell "wrong codec" from "empty document", so the data-loss path and the ordinary path look identical from the inside. That is not hypothetical — it reached production in a consumer's store.
If your medium has nowhere to record the codec, support exactly one and reject the others with ErrUnsupportedEncoding. The suites accommodate that; they will not accommodate guessing.
The suites are adversarial on purpose. An in-process hub is naturally stronger than the Hub contract — ordered, no duplicates, no redelivery — so the suite reorders, duplicates and redelivers. Otherwise the shipped default would quietly become the de-facto contract and the first Redis implementation would fail in production against a suite that passed.
Compatibility with JavaScript Yjs is the correctness test, and it is enforced by a differential oracle rather than by hand-written expectations: random operation sequences run through both implementations and the results are compared byte for byte.
- 13 surfaces — text, array, map, XML, delta application, updates, undo, relative positions, sync, awareness, snapshots, GC, subdocuments
- Two directions, and not every surface runs both. Direction A has the reference produce bytes we consume; direction B has us produce bytes the reference consumes. Only direction B can catch a non-canonical encoding, because in direction A the bytes never originate here. Eight surfaces run both ways — text, array, map, XML, delta application, updates, snapshots, GC. Five are direction A only: undo, relative positions, sync, awareness, subdocuments. For those five, bytes this library produces are not yet differentially checked against the reference. Closing that is
specs/004-full-parity-coverage. Where direction B does run it covers both codecs — the V2 update, snapshot and GC payloads this library emits are decoded by realyjsand re-encoded for comparison on every push, not V1 alone. - Tiers from 20,000 seeds on every push up to 10,000,000
Pinned against yjs@13.6.31, y-protocols@1.0.7, with cross-checks against yrs and ygo. Both the V1 and V2 update codecs are complete and byte-exact, including delta-coded delete sets.
Run it with bash fuzz/run-gate.sh --tier fast --dir both, which needs node and cd fuzz && npm ci.
Large fragmented sequences use an AVL-balanced block index for mutation-position lookup: logarithmic tree descent followed by a bounded block scan. This is an internal accelerator; Yjs semantics and wire formats are unchanged. Formatted-text positioning also depends on preceding formatting boundaries.
In the recorded 256k random-insertion workload, the indexed path was about 9.5x faster than this port's previous marker-cache path (Apple M1 Max, August 2026). This measures a specific local-edit workload, not a speedup over JavaScript Yjs or remote-update application.
Benchmarks live in bench/, with matched workloads implemented four times — this library, yjs, yrs and ygo — driven by the same generator so the comparison is like for like. bench/run-all.sh runs them and bench/status.py reports, refusing to quote numbers measured against a different commit than the one checked out.
See the performance notes and tradeoffs and recorded benchmark results. Results describe specific workloads and the recorded implementation versions and hardware. Positional-edit improvements apply to local sequence operations; they do not predict equivalent gains for servers primarily applying remote updates.
Pre-1.0 and used by Alkemio's collaboration service. Public APIs, including backend contracts, may change between releases. Pin a version and review the release notes before upgrading.
Yjs compatibility is checked against pinned reference versions. The Correctness section describes the tested surfaces and current coverage gaps.
This began as a fork of skyterra/y-crdt by Qinghui Yao, which provided the initial Go port of the Yjs core and the V1 codec. It has diverged substantially since: 60 Go files became 271, and everything below was added or rewritten — the V2 codec, the sync and awareness protocol package, the backend ports and their conformance suites, the differential oracle, the fuzz targets, and the Yjs-parity work across formatting, snapshots, subdocuments, relative positions and undo.
It is a separate project rather than a maintained fork, but the lineage is real and the original copyright stands in LICENSE alongside the current one, as MIT requires.
MIT — see LICENSE, which carries both the original and the current copyright.