Text engine performance
This page records measured performance of the NodalMerge text CRDT engine (nodalmerge-core, RGA with the incremental text projection enabled) on
real-world, character-by-character editing traces. All numbers are NodalMerge
runs — see performance-overview for
methodology framing and how to read benchmark results safely.
Environment
- Run date: 2026-07-06
- Host: AMD Ryzen 9 5900X desktop (12C/24T), Windows 11 Pro
- Build:
cargo test --release,TextProjectionMode::Enabled - Only same-host runs are directly comparable; treat these as this host’s baseline.
What’s inside these numbers
NodalMerge is not a bare text buffer — every number on this page includes the full integrity and auditability model, per operation:- Hash-linked DAG node per transaction. Each edit becomes a node with a blake3 content hash as its identity and explicit parent pointers to the causal frontier. Tampering with history is detectable by construction, and any peer can verify the causal chain it receives. The blake3 hashing and parent bookkeeping happen inside the timed apply loop.
- Stable per-character identity. Every character carries a permanent
(lamport, author)op id — not just an ephemeral position. That is what makes deterministic replay-to-a-point-in-time, cursor anchoring across concurrent edits, and per-author attribution of every character possible. The engine stores and indexes these identities for the whole document, tombstones included. - Policy enforcement on the apply path. Every op key is checked against the room write policy for its author before it is admitted.
- Deterministic audit trail. The applied history is the wire format: a fresh peer replays the same nodes and must reach the same document (the cold-start rows below assert exactly that).
benchmarks/benchmarks.md in the main
repository for signed-mode microbenchmarks.
The per-edit wire cost shown below (~179 bytes for single-character edits) is
the price of that framing: author key, parent hash, and node id travel with
every transaction. Multi-character range edits (paste, bulk operations)
amortize the framing across the whole edit, so realistic client batching
brings amortized overhead down toward content size.
Real-world editing trace (259,778 ops)
Runner:core/tests/b4_editing_trace.rs. The trace is a real
character-by-character editing session of a ~104,852-character LaTeX paper
(182,315 single-character insertions, 77,463 deletions). It is applied the way
a real client would submit it: one transaction per edit, position-based range
ops, single writer, then the final document is extracted and verified
character-for-character against the trace’s known final text.
Every edit carries the full integrity model described above — hashing,
causal parents, per-character identity, and policy checks all execute inside
the timed loop.
Note: the cold-start row above deliberately measures the worst case — a
fresh peer replaying the entire op history with no snapshot available. It is
not the only cold-start path.
DAG compaction produces a single signed snapshot node carrying the canonical
state hash and the frontier it subsumes. The snapshot has empty parents, so
a brand-new peer that has never seen any pre-compaction history can accept it
directly and rebuild as
snapshot + recent delta rather than replaying the
whole log — bounded by state size rather than history length. Incremental
snapshots chain from a previous snapshot, so a peer already holding the base
prunes only the delta.
Compaction is opt-in on the server: --snapshot-interval <N> (default 0,
disabled) triggers a snapshot every N new nodes per room, and
--snapshot-max-chain <K> (default 10) caps incremental chain depth before
the sweeper falls back to a full snapshot. A client can also request one
explicitly, which broadcasts the resulting snapshot pack to every connected
peer.
Compaction does not require trusting the compacting peer. The snapshot node is
Ed25519-signed and verified through the normal apply path, and any receiver
that still holds the pre-compaction log can independently replay it and
compare canonical hashes.
Browser (WASM) throughput on the same trace
The Engine benchmark demo replays the same 259,778-operation editing trace through the WebAssembly build in the browser and verifies convergence against the same expected final document used by the native benchmark. Browser results depend on the client device and browser, so they should be treated as representative measurements rather than fixed constants. The figures below were measured in Chrome on the Ryzen 9 5900X system described above.
Comparing these results on the same hardware isolates the cost of the
execution environment. The gap between the native and browser builds
reflects WebAssembly execution and the JavaScript↔WASM boundary, while the
difference between the unsigned and signed browser runs isolates the cost of
Ed25519 signature verification. All three configurations converge to the
identical final document.
Even with cryptographic verification enabled on every operation, the browser
processes more than 27,000 verified operations per second — comfortably
above the throughput required for interactive collaborative editing. No one
types 27,000 characters per second.
Large-trace throughput and scaling (up to ~980k ops)
Runner:core/tests/text_throughput_and_convergence.rs, replaying a ~980k-op
real editing trace (docs/rustcode.json) both one-apply_remote-per-char
(“unbatched”) and one-apply_remote_batch-per-editing-transaction
(“batched”).
Reading these safely:
- The cold-start column is the purest engine signal: ~2.4 µs/op at 50k ops and ~4.2 µs/op at 980k ops — near-flat in document size. The decay in the replay columns is dominated by the test harness’s own per-op position bookkeeping, not the engine.
- Batched and unbatched apply are equivalent at every size, so client SDKs can batch for transport efficiency without an apply-path penalty.
- The full ~980k-op replay converges to the exact expected final text, and a fresh peer bulk-syncing the entire history converges in ~4.2 s.
State reads (map / list / blob)
StateGraph map, list, and blob-reference reads are served from
incrementally maintained views updated O(1) per op at apply time:
resolve/resolve_canonical/resolve_with_meta: O(live keys) per call; per-key reads (read_speculative/read_canonical) are O(1).resolve_list: O(items) per call.- Host ingest conflict surfacing is O(new ops in the batch).
resolve_1k microbench (1,000 keys,
one write each — the minimum possible history-to-key ratio) improved 66%
(~350 µs → 118 µs). Rooms with realistic history-to-key ratios (long-lived
rooms, frequent overwrites) see proportionally larger wins because read cost
no longer scales with room history.
Correctness guarantees behind these numbers
- Randomized projection-vs-replay parity tests (text) and cache-vs-replay parity tests (map/list) run in the standard test suite.
- Both editing-trace benchmarks assert exact final-document equality, and the cold-start peer must converge to the same content.
- Merge semantics, per-character op identity, public API, FFI, and wire formats are unchanged by the engine work these numbers reflect.