Current sustainability matrix
The July 2026 container matrix exercised the final adaptive-memory engine with two vCPUs per engine case. CDC workloads ran under a 512 MiB hard limit and realtime workloads under a 1 GiB hard limit. A case passed only when the expected sink documents or client deliveries were present; realtime cases also required zero gaps and zero duplicates.Direct CDC
Realtime fan-out at 200 clients
Each case published 20,000 events and verified 4,000,000 client deliveries.
The complete direct, joined-projection, fan-out, CPU, cgroup, and memory results
are in the
general sustainability report.
The public performance page also includes
the sustained fault-recovery validation.
Why it’s fast
- Bootstrap is one optimized query. The snapshot projects the whole source with a single streaming scan per table (Postgres) or one Cypher per spec (Neo4j) — the database planner does the heavy lifting, and rows stream straight into batched bulk-index requests.
- The live path recomposes only what changed. A change fans out to the affected primary document(s) and nothing else. For relational children this uses a reverse index (a deleted/updated child row resolves to its parent in O(1), without scanning).
- Writes are batched + idempotent. Recomposed docs are coalesced into bulk requests and upserted by deterministic doc id, so retries and duplicate CDC deliveries are safe no-ops.
Projection microbenchmarks
These focused local tests isolate projection shape and latency behavior. PG projections embed N child tables; the Neo4j projection walks a 3-hop graph (User → Membership → Group, and
User → Membership ← Grant ← admin User).
Bootstrap (one-shot scan)
Live tail (per-event recompose)
The shape of the projection is what matters
Two takeaways the numbers make obvious:- Bootstrap throughput barely moves with shape. Adding a third child table to the Postgres projection didn’t slow the snapshot — it’s still one scan per table, and bulk indexing dominates.
-
Live cost is dominated by recompose depth. A shallow relational
recompose (re-read a row + its embedded children) runs at ~10k/s — the
extra child relation is marginal. A deep graph recompose runs a
multi-hop Cypher query, which is far heavier than reading a relational
row. The tail coalesces a poll’s events into chunked recompose
queries (rather than one query per event), which lifts the 3-hop graph
case from a per-event ~233/s to ~4,000/s — still slower than shallow
relational, but no longer query-overhead-bound. (The same projection
bootstraps at ~23k nodes/s, because one planner-optimized scan beats
even batched per-key lookups.) The chunk size is tunable via
VS_NEO4J_RECOMPOSE_CHUNK(default 128, the measured sweet spot); going much larger regresses sharply — a giant element-idIN-list degrades the fan-out query plan (Neo4j can drop the indexed element-id seek on the projection branches in favour of a scan). The chunks also run concurrently (VS_NEO4J_RECOMPOSE_CONCURRENCY, default 8, bounded by the bolt pool) — that parallelizes cascade-heavy polls; beyond ~8 it’s Neo4j-query-bound, not round-trip-bound.
Tuning: latency vs throughput, and what to set
Two different things get called “speed”, and they tune differently. Don’t conflate them:- Per-change latency — one row changes; how fast does it appear in the sink? This is what matters for real-time CDC.
- Bulk throughput — a single statement changes millions of rows; how fast does the backlog drain?
Latency is set by the flush/poll window, not the recompose
The recompose itself is cheap when the projection is indexed (see below): a shallow PG recompose at 1M scale is ~2–3 ms. End-to-end latency is dominated by the batch-flush window, which trades latency for sink-write efficiency:
For Neo4j the equivalent floor is
VS_NEO4J_POLL_INTERVAL_MS (default
500) on top of the flush. Lower both for low-latency real-time; keep
them higher to coalesce more events per sink write under heavy churn.
Postgres denormalize mode: bulk speed vs bounded memory
VS_PG_DENORMALIZE_MODE is the big one:
Both bootstrap fast (~130–160k rows/s) and both have ms-scale
single-change latency — the difference is only how a bulk burst of
millions drains. Pick
sql when you can’t hold the working set in RAM;
pick memory when you can and want maximum bulk speed.
Recommended settings by workload
The engine ships with jemalloc + background decay
(
_RJEM_MALLOC_CONF, baked into the image), so RSS tracks the live
working set rather than the high-water mark after a spike — no tuning
needed. Full list in the engine env reference.
WebSocket fan-out (the ws role)
The other pipeline is live fan-out: events arrive from NATS and are
pushed to every connected WebSocket whose subscription matches. The cost
that matters here is fan-out width — how many connections one event
is delivered to.
Final engine, local Docker, single node. 2,000 connections, a
mixed-realistic shape (15% subscribed to a hot orders.>, the rest to a
narrow per-entity pattern), 256-byte payloads, Core mode:
Why it’s fast — one event, one copy, one serialization
A hot subject delivered to N connections does not copy or serialize the event N times:Arc-shared envelope. The dispatcher clones the decoded event into oneArcand hands every matching connection a refcount bump, not a deep copy. (Removing the per-connection deep clone alone lifted burst throughput ~25% and cut the sustained-load p99 from hundreds of milliseconds to ~16 ms — the serial clone loop was stalling the fan-out task.)- Serialize once, splice many. The bytes that arrived off the bus are
already the event’s JSON, so they’re reused verbatim — each delivery
is a cheap string assembly of the tiny per-connection envelope
(
subject, matched subscription ids) around the shared event bytes, not a fresh serialization of the whole envelope. - No slow client stalls the publisher. Each connection has a bounded outbound mailbox; when it fills, that one connection is disconnected as a slow consumer (it must reconnect) — the fast path keeps flowing.
Core vs JetStream throughput
The numbers above are Core mode (VS_WS_JETSTREAM unset): a single
NATS subscriber fans every event out to all connections — the high-
throughput path. JetStream mode gives each connection its own durable
consumer (per-connection cursor, replay-capable) and trades raw fan-out
throughput for that durability: every consumer pulls and acks
independently, so throughput is bound by NATS pull/ack volume rather than
CPU. Pick Core for connection density and live-only delivery; pick
JetStream when a connection needs per-cursor durability. Both share the
serialize-once path above.
Measure your own
The numbers above come from a burst-and-poll method you can run against your own deployment:- Bootstrap your projection and let the agent reach the
tailingphase. - Issue a bounded burst of mutations in one transaction (e.g.
UPDATE … WHERE id BETWEEN …for 10k rows), noting the commit time. - Poll the last document in the range until it reflects the change — that’s the drain time for the whole burst.
events ÷ drain_timeis your end-to-end throughput (CDC decode → recompose → bulk index), on your hardware and projection.
elapsed_ms.