ygo is a pure-Go implementation of the Yjs CRDT (Conflict-free Replicated Data Type) library, enabling real-time collaborative applications in Go backends without CGO or embedded runtimes.
It is binary-compatible with the JavaScript Yjs reference implementation — updates produced by ygo can be applied by Yjs clients, and vice versa.
This library is production-ready. All planned phases are complete, the test suite passes, and the B4 benchmark targets are met. See the CHANGELOG for recent security hardening.
| Component | Status |
|---|---|
encoding/ |
✅ Complete |
crdt/ core |
✅ Complete |
crdt/types/ |
✅ Complete |
| Update encoding V1 | ✅ Complete |
| Update encoding V2 | ✅ Complete |
| Sync protocol | ✅ Complete |
| Awareness | ✅ Complete |
| WebSocket handler | ✅ Complete |
| HTTP handler | ✅ Complete |
| Snapshots / GC | ✅ Complete |
- Pure Go — no CGO, no V8, no embedded JavaScript engine
- Binary-compatible — interoperates with JS Yjs, Yrs (Rust), and any compliant Yjs client
- Full type support — YText, YArray, YMap, YXmlFragment, YXmlElement, YXmlText
- Both update formats — UpdateV1 and UpdateV2 (with V1↔V2 conversion)
- Sync protocol — implements y-protocols SyncStep1/2 and incremental updates
- Awareness — presence, cursor sharing, and ephemeral state
- Snapshots — point-in-time document history and restore
- Transport-agnostic — core logic has no transport dependency; WebSocket and HTTP handlers are addons
- Idiomatic API — designed for Go developers, not a transliteration of the JS API
- Go 1.23 or later
go get github.com/reearth/ygopackage main
import (
"fmt"
"github.com/reearth/ygo/crdt"
)
func main() {
// Create two peers
alice := crdt.New()
bob := crdt.New()
// Obtain the shared type before entering a transaction —
// GetText and Transact both acquire the document mutex.
text := alice.GetText("content")
// Alice makes edits
alice.Transact(func(txn *crdt.Transaction) {
text.Insert(txn, 0, "Hello, world!", nil)
})
// Encode Alice's state and send to Bob
update := alice.EncodeStateAsUpdate()
// Bob applies the update — both docs now converge
if err := crdt.ApplyUpdateV1(bob, update, nil); err != nil {
panic(err)
}
fmt.Println(bob.GetText("content").ToString()) // "Hello, world!"
}The examples/ directory contains four runnable programs with detailed inline comments:
| Example | What it shows |
|---|---|
examples/peer-sync |
In-process two-peer sync via the y-protocols handshake — no network needed |
examples/http-sync |
Pull/push sync over HTTP with incremental state-vector diffs |
examples/collab-editor |
Real-time multi-tab collaborative editor with a browser client |
examples/snapshot-history |
Document versioning — capture, store, and restore past states |
Run any example from the repository root:
go run ./examples/peer-sync
go run ./examples/http-sync
go run ./examples/snapshot-history
go run ./examples/collab-editor/server # then open http://localhost:8080package main
import (
"net/http"
"github.com/reearth/ygo/provider/websocket"
)
func main() {
server := websocket.NewServer()
http.Handle("/yjs/{room}", server)
http.ListenAndServe(":8080", nil)
}# Run all benchmarks with memory allocation stats
go test ./... -run='^$' -bench='^Benchmark' -benchmem
# Run a specific package only
go test ./crdt/ -run='^$' -bench='^Benchmark' -benchmem
# Run with more iterations for tighter confidence intervals
go test ./... -run='^$' -bench='^Benchmark' -benchmem -benchtime=5s -count=3To compare two branches (e.g. before and after an optimization), install benchstat:
go install golang.org/x/perf/cmd/benchstat@latest
# Capture baseline
git checkout main
go test ./... -run='^$' -bench='^Benchmark' -benchmem -count=5 | tee old.txt
# Capture candidate
git checkout my-branch
go test ./... -run='^$' -bench='^Benchmark' -benchmem -count=5 | tee new.txt
# Compare
benchstat old.txt new.txtThe CI benchmark workflow (.github/workflows/benchmark.yml) runs this comparison automatically on every pull request.
Measured on Apple M4 Max (arm64, Go 1.23). Your numbers will vary by hardware.
Encoding (encoding/) — the codec runs on every item; these are sub-10 ns, zero-alloc:
| Benchmark | ns/op | Allocs |
|---|---|---|
| ReadVarUint (1 byte) | 1.0 | 0 |
| WriteVarUint (1 byte) | 1.7 | 0 |
| WriteVarString (1000 chars) | 15 | 0 |
| ReadVarString (1000 chars) | 89 | 1 (string copy) |
Encoder reuse (Reset) vs new |
7.7 vs 12.4 | 0 vs 1 |
CRDT core (crdt/) — realistic document operations:
| Benchmark | ns/op | Notes |
|---|---|---|
YText_InsertBulk (1000 chars) |
2 006 | Single transaction — fast path |
YText_Insert (1000 × 1 char) |
344 048 | ~344 ns per keystroke |
YText_Delete (1000 × 1 char) |
891 456 | ~891 ns per delete |
EncodeStateAsUpdateV1 (1000 items) |
21 360 | ~21 µs to serialise a document |
ApplyUpdateV1 (1000 items) |
109 806 | ~110 µs to integrate a full state |
EncodeStateAsUpdateV2 |
33 029 | V2 is ~1.5× larger to encode… |
ApplyUpdateV2 |
679 207 | …and ~6× slower to decode |
TwoPeerConvergence |
16 284 | Encode + apply incremental sync |
YMap_Set (100 keys) |
19 557 | |
YArray_Push (100 elements) |
59 209 |
Sync protocol (sync/) — message framing overhead is negligible:
| Benchmark | ns/op |
|---|---|
EncodeSyncStep1 |
179 |
ApplySyncMessage_Step1 |
631 |
ApplySyncMessage_Update (1000-item doc) |
1 404 |
FullHandshake |
1 303 |
Awareness (awareness/) — per-peer ephemeral state:
| Benchmark | ns/op |
|---|---|
SetLocalState |
65 |
EncodeUpdate (1 client) |
226 |
EncodeUpdate (50 clients) |
12 901 |
ApplyUpdate (50 clients) |
19 801 |
See docs/ARCHITECTURE.md for a detailed explanation of the CRDT algorithm, data model, and package design.
ygo targets compatibility with:
- Yjs v13.x (JavaScript reference implementation)
- y-protocols sync and awareness protocol
- lib0 binary encoding format
Compatibility is verified by golden-file tests that compare binary output byte-for-byte with Yjs-generated fixtures.
Transact acquires the document write lock for the duration of its callback.
Calling any of the read methods (Get, ToSlice, Keys, Entries, ToString,
ToDelta) or registering/unregistering observers (Observe, ObserveDeep) from
inside a Transact callback will deadlock because those operations try to
acquire the same lock.
// ✗ WRONG — deadlocks
doc.Transact(func(txn *crdt.Transaction) {
arr.Get(0) // tries to RLock — deadlock
arr.Observe(fn) // tries to Lock — deadlock
})
// ✓ CORRECT — acquire references and register observers before Transact
arr.Observe(func(e crdt.YArrayEvent) { /* ... */ })
doc.Transact(func(txn *crdt.Transaction) {
arr.Push(txn, []any{"value"})
})
fmt.Println(arr.ToSlice()) // read after Transact returnsThis constraint applies to YArray, YText, YMap, YXmlFragment, and
YXmlElement. UndoManager callbacks (OnStackItemAdded) also run outside
the lock and are safe to use normally.
Use crdt.WithClientID(id) at construction time. Changing the ID after the
document has started accepting operations will corrupt the item store.
Contributions are welcome! Please read CONTRIBUTING.md before submitting a pull request.
For significant changes, open an issue first to discuss what you'd like to change.
Please report security vulnerabilities by following the process in SECURITY.md. Do not open public issues for security problems.
MIT License — see LICENSE.
This project is not affiliated with the Yjs authors. Yjs is developed by Kevin Jahns and contributors.