Skip to content

Internals

Performance

How KiteDB keeps reads and writes fast

This page explains where KiteDB's read and write latency comes from, shows measured results, and covers the settings that trade durability for throughput.

What keeps latency low

1. No network round trip

Client-server database
AppNetworkServerStorageNetworkApp

Each query is serialized, sent over a socket, executed in another process, and sent back.

KiteDB, embedded
AppKiteDBMemory-mapped file

Queries are function calls into a library in your process.

Key lookup
42 ns
1-hop traversal
42 ns
Commit 100 nodes
18 µs

The p50 figures come from the benchmark run described under benchmark results.

2. Memory-mapped snapshot

Read into buffers
DiskKernel bufferUser bufferParseUse

Data is copied into a user buffer, then decoded into objects before the query can use it.

KiteDB snapshot, memory-mapped
DiskPage cacheRead in place

Uncompressed snapshot sections are read directly from mapped pages. The OS keeps hot pages in RAM and evicts cold ones.

Hot data stays in RAM and cold data is paged in on demand. Checkpoints compress snapshot sections with zstd by default; a compressed section is decompressed once and cached in memory, so only uncompressed sections are read in place.

3. Cache-friendly data layout

Reading 10 neighbors of node n
Pointer-based adjacency
0x7f3a100x1c08200x9e44100x2b7730…

10 dependent loads at scattered addresses. Each next address is known only after the previous load finishes, and each load can miss the CPU cache.

CSR adjacency
out_offsets
n-1
32
n
40
n+1
50
n+2
53
out_dst
38
39
40
41
42
43
44
45
46
47
48
49
50
51

Two adjacent offsets give the range [40, 50). The 10 neighbor IDs are contiguous u32 values, 40 bytes in out_dst and 40 bytes in out_etype: one or two cache lines each, read sequentially.

Measured 1-hop outgoing traversal from a random node: 42 ns p50 (10k nodes, 50k edges).

4. Lazy MVCC

Version chains only when needed
No other open transactionsserial workload

A commit applies its changes to the delta. No version chain is written.

Other transactions openconcurrent workload

A commit also appends a version for each changed node, edge, and property, so transactions on older snapshots keep a consistent view. The cost grows with the number of changes made while other transactions are open.

MVCC is on by default since 0.3.0. Old versions are removed by MVCC garbage collection.

Benchmark results

Single-file raw benchmark on the Rust core (10k nodes, 50k edges, 3 edge types, 10 edge props, 1k vectors of 128 dims, sync=normal, MVCC on), Apple M5 Pro, October 3, 2026. Each row is the median of five interleaved runs.

Node operations

Operationp50p95
Key lookup (random existing key)42 ns208 ns
Batch write (100 nodes)17.92 µs46.79 µs

Edge operations

Operationp50p95
1-hop traversal (out, random node)42 ns84 ns
Edge exists (random pair)42 ns83 ns
Batch write (100 edges)16.08 µs25.92 µs
Batch write (100 edges + props)65.50 µs82.21 µs

Raw log: docs/benchmarks/results/2026-10-03-r2-single-file-raw-rust-mvcc-normal.txt. The benchmarks pages have the other runs and their commands.

MVCC cost

Throughput with MVCC on against MVCC off on the same machine, from mvcc_overhead_bench (sync off, so a commit costs CPU rather than an fsync wait; each case is the median of five runs):

WorkloadMVCC offMVCC onChange
node_prop, 1 reader65.10M/s65.90M/s+1.2%
node_props, 1 reader11.49M/s11.65M/s+1.4%
out_edges, 1 reader18.00M/s18.36M/s+2.0%
node_prop in read transactions, 1 reader59.00M/s56.29M/s-4.6%
node_prop, 8 readers12.67M/s12.68M/s+0.1%
node_prop, 4 readers beside 1 writer7.89M/s7.62M/s-3.4%
Update one prop, 1 writer1.27M/s1.18M/s-7.6%
Insert a node + prop + edge, 1 writer618K/s570K/s-7.8%

Source: 2026-10-03-r2-mvcc-overhead.txt (modes=off,on nodes=20000 edges/node=8 props/node=4 duration=1s repeat=5 history=2000x8 sync=Off cpus=15)

Writer threads need MVCC: without it, write transactions run one at a time. With syncMode=Normal, one writer without MVCC reaches 5.0K/s with transactions of 200 nodes and 200 edges, and eight MVCC writers reach 12.7K/s (2.5x).

Write durability vs. throughput

  • Defaults: syncMode=Full: a commit returns once it is fsynced; commits that arrive together share the fsync.
  • Single writer, low latency: syncMode=Normal.
  • Several writer threads: syncMode=Normal (or Full, whose fsyncs the commits share). With MVCC (the default), writers build their transactions in parallel, and commits that arrive together are written as one group. For one-shot ingest, a bulk load through one writer is still fastest. See the parallel write scaling notes.
  • Fastest, least durable: syncMode=Off, for tests and throwaway data only.

Every commit is group-committed: the commits that arrive while a group is written form the next group, written with one WAL write, one header write and, in Full mode, one fsync, then published in order (each commit whole). No commit waits for others to join, so a single writer pays nothing for it. This works in every sync mode, with MVCC and on a replication primary; groupCommitEnabled and groupCommitWindowMs have no effect.

Decision table

WorkloadsyncModeWhy
Production, high durabilityFullA commit returns once fsynced; concurrent commits share it
Single-writer ingestNormalLowest latency per commit
Several writer threadsNormalConcurrent commits share a WAL write and a header
Testing, throwaway dataOffFastest, weakest durability

Performance playbook

  • Fastest ingest (single writer): beginBulk() + createNodesBatch() + addEdgesBatch() / addEdgesWithPropsBatch(), syncMode=Normal, and the default checkpoint settings: a full WAL spills into WAL segments and checkpoints run on the checkpoint thread, so the WAL needs no resizing (a larger one means fewer spills, but raises the floors of the checkpoint trigger, the segment limit and the segment extent). With auto-checkpoint off, writes fail once the WAL segments reach walSegmentLimit or fill the segment table (63 extents; with default extents about four times a limit up to 512 MiB, and 2 GiB beyond): raise the limit, and walSegmentSize with it past that, to hold the whole load, then checkpoint.
  • Several writer threads: syncMode=Normal, several operations per transaction, and a retry for commits that fail with a conflict.
  • Read-heavy, mixed workload: keep write batches small, leave auto-checkpoint on (it runs once the log reaches half the snapshot's size, at least 3/8 of the WAL, at most 128 MiB; tune with checkpointLogRatio and checkpointLogBudget), and bound traversal depth.
  • Fastest, least durable: syncMode=Off, for testing only.

A bulk load of 200k nodes and 1M edges with 2 properties each, in batches of 5,000, ran at 2.88M/s for nodes and 1.00M/s for edges with MVCC, and 2.90M/s and 975K/s without it (Apple M5 Pro, examples/bulk_load_bench.rs). It runs alone among writers, and readers never wait for it. A read transaction held open across the load slows its edge inserts (615K/s in the same test, with nodes at 2.95M/s), because the load's commits record version history for it. Use it for one-shot ingest or ETL jobs.

Bulk ingest example (low-level API)

TypeScript
// Bulk ingest with the low-level APIdb.beginBulk();const nodeIds = db.createNodesBatch(keys); // keys: string[]db.addEdgesBatch(edges); // edges: { src, etype, dst }[]db.addEdgesWithPropsBatch(edgesWithProps);db.commit();// Optional: checkpoint after ingestdb.checkpoint();

Best practices

Batch writes

TypeScript
// Slow: one transaction (and one WAL sync) per nodefor (const key of keys) {  db.begin();  db.createNode(key);  db.commit();}// Fast: one bulk-load transaction for the whole batchdb.beginBulk();db.createNodesBatch(keys);db.commit();// Rust core benchmark: 100 nodes per batch, 17.92 µs p50 (syncMode=Normal)

Limit traversal depth

TypeScript
// Potentially expensive: deep traversalconst alice = db.get(user, 'alice');const all = db  .from(alice)  .traverse(follows, { direction: 'out', maxDepth: 10 })  .nodes()  .toArray();// Safer: bounded traversal + limitconst friends = db  .from(alice)  .traverse(follows, { direction: 'out', maxDepth: 2 })  .take(100)  .nodes()  .toArray();

Use keys for lookups

TypeScript
// Fast: key lookup (O(1) hash index)const alice = db.get(user, 'alice');// Slower: property scan (O(n) nodes, done in JS)const aliceByName = db.all(user).find((u) => u.name === 'Alice');// Design keys to match your access patterns.

Checkpoint timing

TypeScript
// After a large ingest, fold the delta into a fresh snapshotawait importLargeDataset();db.checkpoint();// Inspect storage statsconst stats = db.stats();

Memory usage

Where memory goes
1. Snapshotmemory-mapped
  • File pages live in the OS page cache: hot pages stay in RAM, cold pages are read from disk on demand.
  • Checkpoints compress sections with zstd by default. A compressed section is decompressed into process memory and cached.
2. Deltain memory
  • Hash maps of created and modified nodes, edge patches, edge properties, and key changes.
  • Each added edge is recorded twice, once per direction.
  • Grows with writes until a checkpoint folds it into a new snapshot.
  • Takes about ten times the bytes of log it holds, so checkpointLogBudget (default 128 MiB of log, about 1.3 GB of delta) bounds it at the checkpoint trigger while checkpoints keep up; writers that outrun them grow the log up to walSegmentLimit, paced while a checkpoint runs (each commit waits up to 100 ms, so the room left lasts the run). A checkpoint briefly needs about twice that.
3. MVCC version chainswhile transactions are open
  • Only written when a commit lands while other transactions are open.
  • Removed by garbage collection once no open transaction needs them (every 5 s by default).

Profiling tips

TypeScript
// Get database statisticsconst stats = db.stats();console.log(stats);// {//   snapshotNodes: 100000,//   snapshotEdges: 500000,//   deltaNodesCreated: 1200,//   deltaEdgesAdded: 3400,//   walBytes: 10485760,//   recommendCompact: false// }// If recommendCompact is true, run db.checkpoint()

Next steps