AbsoluteJS

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.

1
Subscribe to a query
Clients subscribe to a query. The engine returns the initial result and pushes updates whenever anything that query depends on changes.
2
Writes go through mutations
Writes go through server-authored mutations. The mutation transacts against the durable store and emits the changes; subscribers see them atomically after commit.
3
Automatic dependency tracking
The engine notes what tables/keys a query read, and only re-runs (or invalidates) when something in that read-set is written.
4
Mutation results as the ack
Mutation results are pushed back to the calling client as the ack, so the optimistic edit can settle against authoritative state.

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.

DimensionConvexsync
RuntimeManaged — hosted V8 isolates with seeded ChaCha12 RNG and a frozen Date.now() inside mutations; every mutation is automatically deterministic, retryable, and replayableA library you import — runs on your Bun (or Node) process, alongside your other Elysia routes; no managed control plane
Mutation handlersJS-only — the runtime decides what your handler can reachArbitrary host JS — TypeScript, async, with access to anything you've imported; fast, full-power, no sandbox by default
DatabaseConvex's own database under the runtime — replication, point-in-time recovery, multi-region failover are theirs to operateBrings 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)
PackagingOne product — you opt in or out; there's no piecemealPick 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.

FeatureConvexsync
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-partyfirst-party (/crdt)
Schema validation
defineSchema + field()
runtime-enforceddeclare + enforce
Devtools / inspector
console + recent changes
dashboardengine.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 onlyany
Handler language
TS, async, host imports, your runtime
JS onlyany 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
managedDIY
Hosting model
hosted through AbsoluteJS.ai or self-host the library
managed onlyhosted 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-pushsync per-pushRatio
10012.4 KB118 B105×
1,000116.3 KB118 B986×
5,000577.1 KB118 B4,888×

Same workload, same Convex deployment, same sync engine. Sync's wire format encodes only the delta; the client reconciles into its local store.

Convex's #95 also notes a 8192-element cap on array returns and a 4096-read cap per function — neither applies to sync.

#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 offDB hits, cache on (default)
1001001
1,0001,0001
10,00010,0001

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.

TS
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:

User-supplied sourceThe handler source is user-supplied (multi-tenant PaaS, plugins).
AI-generated sourceThe source is AI-generated and you need hard caps before running.
Defense-in-depthYou want defense-in-depth resource limits on your own first-party handlers — capped CPU/memory keeps a runaway from taking the engine down.

#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? }):

TS
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:

TS
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.

When Convex's version is better
Convex's storage layer is designed for forever-retention out of the box, so any historical timestamp resolves exactly. Sync's accuracy is bounded by your retention policy — set the window wide for forensics, narrow for short-tail use cases.

#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.

TS
// ── 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();
}
fence({ reason })Pauses new mutations on the source so its captured state stops drifting. 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.
exportSnapshot()Walks each registered reader's all(ctx) and returns a portable EngineSnapshot { sourceInstanceId, version, exportedAt, tables }. Optionally narrow with { tables: [...] } for partial migrations.
importSnapshot(snapshot, options)Bulk-loads via each table's registered writer on the target. 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.

Out of scope
Out-of-band writes (CDC drivers, raw SQL). The fence only blocks 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.

CapabilityConvexsync
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:

You want one managed product end-to-end.
JS-only mutation handlers are fine.
You don't need to choose your database — Convex's is good and you'd rather not operate one.
You want strict determinism guaranteed by the runtime, with no developer responsibility for it.
You're not running Bun (Convex doesn't run on Bun specifically; it has its own runtime).

Pick sync if:

You already have a database (Postgres / MySQL / SQLite) and want to keep it as the source of truth.
You want your handlers to be normal TS code with access to your other libraries and your own runtime.
You want first-party CRDT support with pluggable backends (Yjs, Automerge, Loro).
You want to use sandboxing on some handlers (untrusted source, PaaS, AI-generated) but not pay the cost on the fast path.
You're running on Bun and want native primitives.
You want to host it yourself (or wait for the absolutejs PaaS).

#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:

No backwards-compat burdenWe didn't have to commit to backwards compat. Convex has paying customers; we can move fast on shape.
We don't host the DBThey have to design for multi-region; we delegate that to your existing infra.
CRDT was already shippedThe CRDT story was already shipped in absolutejs (/crdt) before sync started, so we slotted in.
The targets were written downConvex's gaps are well-tracked in their public issue tracker (#95, etc) — we had the targets written down.
The gaps we still have (managed deployment, strict determinism, managed multi-region failover) are real and not artifacts of "we're new." They're host-operator concerns and will land alongside the absolutejs hosted PaaS. We're documenting them honestly here so you can plan.