Skip to main content

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).
These runs use unsigned nodes (signing is optional). With Ed25519 signing enabled, verification adds a per-node cost that is amortized by the batched parallel verify path on catch-up; see 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).
Measured floor of the improvement: the 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.