Sync vs Convex
Convex is the closest comparison point for @absolutejs/sync. The mental model is the same — reactive subscriptions, server-authored mutations as transactions, automatic dependency tracking. The differences are in where the engine runs, what language it speaks, and what escape hatches it gives you. This page is the honest comparison.
This page focuses on sync-vs-Convex specifically. The seven cross-cutting gaps the substrate audit named (audit log, OTel, dispatch, cluster bus, replay, migration) are consolidated on Substrate complete (G1–G7).
#Shared mental model
If you've used Convex, sync's API will feel familiar. Both engines collapse "store + cache + invalidation + push" into one abstraction.
Where the two diverge is under that surface — see "Where the engine runs" below.
#Where the engine runs
Convex is a managed runtime + managed DB; sync is a library you import into your own Bun server, talking to your own DB.
| Dimension | Convex | sync |
|---|---|---|
| Runtime | Managed — hosted V8 isolates with seeded ChaCha12 RNG and a frozen Date.now() inside mutations; every mutation is automatically deterministic, retryable, and replayable | A library you import — runs on your Bun (or Node) process, alongside your other Elysia routes; no managed control plane |
| Mutation handlers | JS-only — the runtime decides what your handler can reach | Arbitrary host JS — TypeScript, async, with access to anything you've imported; fast, full-power, no sandbox by default |
| Database | Convex's own database under the runtime — replication, point-in-time recovery, multi-region failover are theirs to operate | Brings your own — first-party adapters for Postgres / MySQL / SQLite, plus Drizzle and Prisma; the engine treats your DB as the source of truth, and replication / backups / regions are your choice (same way you'd run a normal Elysia app) |
| Packaging | One product — you opt in or out; there's no piecemeal | Pick the pieces you need — use just reactive subscriptions, or add CRDT, search, scheduled jobs, cluster bus, sandboxing; each is opt-in via a sub-package |
Both shapes are valid. Convex trades flexibility for guarantees; sync trades guarantees for flexibility (with opt-in ways to claw the guarantees back when you want them).
#Feature matrix
Side-by-side of what each ships today. Annotated sections below dig into the rows that have the biggest implementation differences.
| Feature | Convex | sync |
|---|---|---|
| Reactive subscriptions same mental model | ||
| Server-authored mutations mutations are the only way to write | ||
| Automatic dependency tracking read-set + invalidation | ||
| Optimistic mutations | ||
| Cross-client query coalescing "one query body per (code, params, read-set)" — sync since 1.3 | ||
| Local-first / offline catch-up via change log on reconnect — sync Tier 2 | ||
| Built-in permissions row-level + per-mutation authorize | ||
| Live search text + vector indexes | ||
| Scheduled functions / cron @elysiajs/cron + registerSchedule | ||
| CRDT collaboration RGA text + LWW reg + PN-counter + adapters | third-party | first-party (/crdt) |
| Schema validation defineSchema + field() | runtime-enforced | declare + enforce |
| Devtools / inspector console + recent changes | dashboard | engine.inspect() |
| Sandboxed handler execution sandboxedHandler + @absolutejs/isolated-jsc | always-on (V8) | opt-in (1.4) |
| Determinism guarantees sync trusts the developer; sandbox is opt-in | strict (managed) | developer-owned |
| DB choice Postgres, MySQL, SQLite, Drizzle, Prisma | Convex DB only | any |
| Handler language TS, async, host imports, your runtime | JS only | any host code |
| Time-travel queries engine.replayTo({ at, tables? }) — sync 1.22, see below | ||
| Tenant migration primitives engine.fence / exportSnapshot / importSnapshot — sync 1.24 | ||
| Multi-region replication host-level concern, no managed offering | managed | DIY |
| Hosting model hosted through AbsoluteJS.ai or self-host the library | managed only | hosted or self-host |
#Row-level diffs vs full results
Convex pushes the full query result on every change (tracked upstream as get-convex/convex-backend#95). Sync emits row-level { added, removed, changed } diffs. Same workload, the wire savings get dramatic with query size.
Measured against an N-row reactive query, one row changed per push:
| K (rows) | Convex per-push | sync per-push | Ratio |
|---|---|---|---|
| 100 | 12.4 KB | 118 B | 105× |
| 1,000 | 116.3 KB | 118 B | 986× |
| 5,000 | 577.1 KB | 118 B | 4,888× |
Same workload, same Convex deployment, same sync engine. Sync's wire format encodes only the delta; the client reconciles into its local store.
#Cross-client query cache
Convex pioneered the "one query body per (code, parameters, read-set) executes only once" coalescing that makes their cost model work at scale. Sync had the prerequisites already (read-set tracking, stable sub-keys) since 1.0; 1.3 lifted the existing per-batch dedup to a persistent LRU + TTL cache shared across batches.
Subscribe-storm bench — N fresh subscribers to the same query:
| N (subscribers) | DB hits, cache off | DB hits, cache on (default) |
|---|---|---|
| 100 | 100 | 1 |
| 1,000 | 1,000 | 1 |
| 10,000 | 10,000 | 1 |
Configure via createSyncEngine({ reactiveCache: { max, ttlMs } }). Defaults are bounded (256 entries, 60s TTL). Different ctx references stay isolated, so per-user query bodies aren't accidentally shared.
#Sandboxed handlers
Convex's V8-isolate runtime sandboxes every handler by default — that's the price of admission and also the guarantee. Sync inverts the choice: handlers are normal host JS by default (~50 ns per call, full host access, integrate with anything), with an opt-in sandboxedHandler per mutation when the source is untrusted or you want hard CPU/memory caps.
defineMutation({
name: 'transfer',
sandbox: { memoryLimit: 32, timeout: 1000 },
sandboxedHandler: `async (args, ctx, actions) => {
await actions.update('accounts', { id: args.from, balance: ... });
await actions.update('accounts', { id: args.to, balance: ... });
}`,
});Sandboxed handlers run inside @absolutejs/isolated-jsc — a Bun-native JavaScriptCore Isolate with a separate heap. First call per mutation pays ~30 ms (Worker spawn + compile). Every subsequent call reuses the isolate at ~0.5 ms. Timeout terminates the isolate; the next call transparently re-spawns. @absolutejs/isolated-jsc is an optional peer dep — only loaded when the first sandboxed mutation runs.
Built because isolated-vm is V8-only and doesn't load in Bun (Bun uses JavaScriptCore, not V8 — see oven-sh/bun#23653). The library fills that gap; sync wires it. See the dedicated Sandboxed Mutations page for backends, bench numbers, and the full trade-off table.
Use sandboxedHandler when:
#Point-in-time replay
Convex's time-travel queries let you read against any past commit — the killer feature for "I deleted prod, restore us to 2h ago" and forensic "what did the tenant see at 14:32?" stories. Sync 1.22 shipped the same primitive as engine.replayTo({ at, tables? }):
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
const result = await engine.replayTo({
at: twoHoursAgo,
tables: ['orders']
});
if (result.truncated) {
console.warn('Best-effort — log retention window too short.');
}
console.log(result.rows.orders);How it works: sync's change log is bounded by changeLogSize + changeLogRetainMs. replayTo walks the log forward to the target timestamp, folds each insert/update/delete into a per-table keyed view (last-write-wins per row key, delete removes), and returns { asOfVersion, asOfAt, rows, truncated }. truncated=true when the log doesn't extend back to the target — result is best-effort from the oldest retained entry.
For forensic use cases, set changeLogRetainMs wide:
createSyncEngine({
changeLogSize: 100_000,
changeLogRetainMs: 14 * 24 * 60 * 60 * 1000 // 14-day window
});Sync 1.23 added a Replay panel to syncDevtools — a datetime picker, optional tables filter, and a clickable Replay button. The same endpoint is exposed as JSON at GET <devtoolsPath>/replay so admin shells can wrap it without screen-scraping HTML.
#Tenant migration
Moving a tenant between engines (sharding rebalance, cross-region move, point-in-time clone for staging) is a first-class operation in sync 1.24. Three composable verbs — fence, exportSnapshot, importSnapshot — let you choreograph the strictness vs availability tradeoff yourself instead of taking whatever a monolithic migrate() would prescribe.
// ── on the source ──
const fence = source.fence({ reason: 'tenant-7 → us-east-2' });
try {
const snapshot = await source.exportSnapshot();
await transport(snapshot); // S3, message bus, your choice
// ── on the target ──
await target.importSnapshot(snapshot, {
onProgress: (table, done, total) =>
observability.gauge('migrate.rows', { table, done, total })
});
await dnsCutover(); // direct clients at target
} finally {
fence.lift();
}runMutation rejects with EngineFencedError carrying the reason. Reads keep working — subscribe / hydrate / streamChanges stay open, so live readers don't go dark during the transfer. Multiple fences compose: every handle has to lift() before the engine unfences.all(ctx) and returns a portable EngineSnapshot { sourceInstanceId, version, exportedAt, tables }. Optionally narrow with { tables: [...] } for partial migrations.onProgress fires per row. Tables in the snapshot without a writer on the target are surfaced in result.skipped so a misconfigured target doesn't silently drop rows.Why three verbs, not one big migrate(): a monolithic call would conflate pause-writes, capture-state, transport-bytes, and reapply — but transport is your choice (S3? Kafka? gRPC?) and the strictness vs availability tradeoff (fence-first vs export-first) is operator policy. The substrate offers the verbs; the choreography is yours. Reads stay open under fence so live subscribers don't go dark during the transfer.
runMutation; halt CDC separately or the snapshot will drift between export and import.#What sync doesn’t have yet
Honest list of where Convex is ahead and we haven't caught up. The sync library remains self-hostable, while the AbsoluteJS.ai platform provides the hosted product experience.
| Capability | Convex | sync |
|---|---|---|
| Strict determinism Convex enforces it; sync trusts you — opting into sandboxedHandler enforces resource caps but not "no random / no real time" determinism. | ||
| Multi-region with managed failover Convex handles this for you; in sync this is a deployment choice — Postgres logical replication or Redis pub/sub via @absolutejs/sync-bus-redis works, but you operate it. |
#When to pick each
Neither answer is wrong. The decision is mostly about whether the managed-runtime / managed-DB trade is one you want.
Pick Convex if:
Pick sync if:
#Honest framing
Sync's first commit was 2026-05-23. Convex is years old, well-funded, with a real team. This page records what we've shipped and where the trade-offs land today; it's not a verdict on the products.
We were able to close most of the architectural gaps quickly because:
/crdt) before sync started, so we slotted in.