Evolu's blind-relay sync, run through the same 8 offline-first failure modes
Evolu's blind-relay sync, run through the same 8 offline-first failure modes I keep a short checklist of the ways offline-first sync actually breaks in production. Eight items, in the order they bite. I already ran RxD
Evolu's blind-relay sync, run through the same 8 offline-first failure modes
I keep a short checklist of the ways offline-first sync actually breaks in production. Eight items, in the order they bite. I already ran RxDB's replication design through it. This time I ran Evolu, because it makes the opposite bet: instead of a smart backend and a dumb checkpoint, Evolu keeps a replicated change log and makes the server blind.
Short version: Evolu moves more of the eight into the protocol than RxDB does, and it is unusually honest about the two it does not solve (true deletion and migration). The interesting part is what the inversion costs: reconciliation orders changes by a clock the client supplies, and a relay that cannot read your data can still silently swallow a change.
Source: the Evolu docs, read this week: local-first, privacy, relay, and time-travel. Evolu 8, SQLite on every device, E2E encrypted sync through relays you can host.
How the protocol works, in one paragraph
Every device holds a real SQLite database. Mutations write locally first; sync happens in the background through one or more relays. Every row gets client-minted system columns (createdAt, updatedAt, isDeleted, ownerId). Sync is not "send the row" β it is a per-column change log: Evolu stores column changes, including the delete flag, in evolu_history keyed by (table, id, column, timestamp), which is also what powers its time-travel API. Relays reconcile changes by their timestamps, which is why timestamps travel unencrypted while the values themselves are XChaCha20-Poly1305 encrypted (keys derived with SLIP-21) and padded with PADMΓ. A delete is a soft delete: the flag replicates, the history stays. Now the checklist.
1. Operation identity / idempotency
RxDB openly hands this to the backend: "add a unique write id field." Evolu does the opposite. Identity is structural. Every row has a client-minted 16-byte ID, and there are deterministic converters for external IDs (createIdFromString hashes a string to an ID; uuidToId/ulidToId are reversible). A change is identified by (table, id, column, timestamp), and the relay keeps the first change it receives for a timestamp. So a retried upload of the same change is a no-op by construction β no write-id column to remember.
Verdict: solved in-protocol, and better than most engines manage. Hold that thought: the same key is what a hostile relay attacks in item 6.
2. Sync queue drain
Writes are local-first: the mutation lands in encrypted SQLite before any network call. Unsynced changes live in the database, not in memory, and mutations queued in the same microtask are applied in one SQLite transaction. The real drain risk here is not the queue β it is the storage under it. On the web, Evolu reports devicePersistence as Persisted, NotPersisted, or Unknown, and only the first is a promise. A browser can evict the database (Safari drops site data after seven days without a visit), and unsynced changes are the only data that exists nowhere else.
Verdict: the queue is durable. The device may not be. Read devicePersistence and tell the user when it is NotPersisted.
3. Deletes
Soft delete, stated as a rule. "Synced Evolu tables mark rows as deleted and retain their history so devices can merge changes after reconnecting." Local tables, whose names start with _, delete permanently and keep no sync history. This is the correct tombstone design: a hard delete resurrects from any device still holding the row.
The honest gap is bigger, and Evolu names it. Enforcing true deletion across every device β including future ones β "would require complex logic to reject the data forever, without exposing the original data." So it is planned, not shipped. And deleteOwner / deleteDatabase are declared but currently throw.
Verdict: correct by construction for merge, with a documented "we cannot yet truly forget this" limit. If your product owes a regulator a real erasure story, that is a design constraint, not a bug.
4. Conflict rule
This is where Evolu diverges sharply from RxDB, and where I would put my attention. Reconciliation is by timestamp. The docs state it plainly: relays "reconcile changes by their timestamps," which is exactly why timestamps are the one thing sent in the clear. And a timestamp "records when a change was made, even offline."
Compare that to RxDB, whose docs warn: "client side clocks can never be trusted. The backend should overwrite the updatedAt timestamp." RxDB puts a server-authoritative clock on top of the client's. Evolu, by design, cannot: the relay is a blind buffer that cannot read the changes it orders. So the ordering input is a client-supplied timestamp with no server overwrite in the protocol.
Skew is therefore not hypothetical β it is a property of the design to plan for. A device with a fast clock wins conflicts it should lose (or, offline, imports an "as-of" time that reorders history). Where a business rule depends on who wrote last, make the winner explicit in your data, because the protocol's answer is "whoever's device said a later time."
Verdict: explicit and end-to-end, but the ordering rests on clocks the protocol cannot verify. Same class of hazard RxDB warns about, without the server-side fix.
5. Local IDs
Strong, and unusually well documented. Primary keys are branded types (Evolu.id("Todo")), so a TodoId cannot be passed where a UserId is expected, and keys are client-generated 16 bytes β no server auto-increment. For integration with systems that already have IDs, createIdFromString is deterministic (first 16 bytes of SHA-256), so two clients that see the same external key mint the same Evolu ID rather than duplicating. The FAQ uses exactly this to kill the duplicate-onboarding-row bug.
Verdict: solved, and the deterministic-ID trick is worth stealing even if you never use Evolu.
6. Partial apply
Two things to separate. Local: a batch of mutations is applied in one SQLite transaction, so a local batch is atomic. Remote: sync's unit is the per-column change, and Evolu has no foreign keys at all β it is built on Kysely, which the FAQ is careful to say "is not an ORM" and "does not have the concept of relations." Nothing in the schema or the protocol enforces parent-before-child. A child's change can reach a device before its parent's, and there is no FK to throw. Your query layer has to tolerate a not-yet-arrived parent.
Verdict: no relational ordering or integrity guarantee. If your schema has relations, that is yours to sequence and to tolerate.
The sharper remote hazard lives here too. The privacy docs describe what a write key can do to a relay: a writer can store data under "the timestamp of a change that hasn't reached that relay yet," displacing it, and can include "timestamps whose fingerprints combine to equal a change's," which makes sync through that relay skip that change, with "nothing reports it." That is a silent divergence β a change that exists on some devices and not others, with no error. It requires a hostile or careless writer with the owner's write key, but the mechanism is documented, and silent is the worst property a sync bug can have.
Verdict: per-change units, no relational integrity, and a documented silent-skip path. Add your own divergence detector if you rely on one relay.
7. Ordering
Timestamps order the change log, so this loops back to item 4: ordering is inherited from a client clock. The relay adds throughput behavior worth knowing β it "serves each connection's requests one at a time" β but that is a performance concern, not a correctness one. The correctness concern is the same client-supplied timestamp, plus the fingerprint-collision skip above.
Verdict: timestamp order, with the clock caveat from item 4.
8. Schema migration
Schema is append-only, and the docs are blunt: "Once an app is released, do not rename or change the type of an existing table or column β only add new tables or columns." That is a real, hard constraint, and it is the correct one for a history-based log: renaming a column orphans its history.
Evolu also does not soften the surrounding work. Evolu 8 "uses a different local storage format and does not migrate existing Evolu 7 databases yet" (v7 data is preserved but not shown). deleteDatabase and deleteOwner throw. There is no built-in account management yet.
Verdict: the hardest edge, and the migration/purge tooling is explicitly unfinished. Budget for it; treat your schema's first release as permanent.
The takeaway
RxDB and Evolu solve the same eight items in opposite directions. RxDB keeps a dumb, resume-able checkpoint and pushes idempotency, conflict, and ordering onto your backend β "the protocol makes the backend simple, not safe." Evolu inverts it: a replicated per-column change log and blind relays mean identity, deletes, and conflict handling are in the protocol, and the server needs no application logic at all.
The cost of the inversion is specific, not vague:
- Ordering rests on a clock the protocol cannot verify. No server overwrite, because the server cannot read the data it orders.
- The write key is permanent. "A relay keeps the first write key it receives for an owner... it can't be rotated yet, and a leaked one can't be revoked." To remove a collaborator you move the data to a new owner and stop syncing the old one.
- A relay that cannot read your data can still silently drop a change via a timestamp fingerprint collision, and nothing reports it.
None of this makes Evolu a bad choice β it makes it a different contract. If you need no backend logic and accept append-only schemas and an unfinished purge story, Evolu carries more of the eight for you than almost anything else. If you need a revocable write path or server-authoritative ordering, that is precisely where the blind-relay design hands the problem back.
Either way, the checklist is the same eight questions. Run them against whichever engine you pick, before you ship.
I wrote a free 8-point self-check for exactly these failure modes. If you want a second set of eyes on a specific repo, I do a 48-hour sync audit for $20: I read your replication/queue/conflict code and return a prioritized list (each item: the bug, the test that reproduces it, the fix). Reach me at [email protected].
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.