Sync modes: local-first vs server-authoritative
Copy page
Everything about how a Nizhal app feels comes down to one question:
When you write something, who do you believe — the device or the server?
Nizhal lets you answer that per app (and even per collection) with one option, mode. There are
exactly two answers, and once you pick one, everything else (offline behavior, conflicts, what can
break) follows from it. So let’s build both answers up from scratch.
A shopping list, two ways
Section titled “A shopping list, two ways”Imagine two flatmates keeping a shared shopping list.
Way 1 — everyone has their own notebook. Each person writes into their own notebook the instant they think of something. No asking permission, no signal required — you can add “milk” standing in a basement car park. Later, when the flatmates are in the same room, they reconcile: copy each other’s new items across. Your notebook is the truth for you; meeting up is just syncing.
Way 2 — one whiteboard at the front door. There’s a single master list on a whiteboard. You can scribble a sticky note (“milk?”) so you don’t forget, but it isn’t really on the list until someone walks to the door and copies it onto the whiteboard. If two people scribble conflicting things, the whiteboard is the tie-breaker. In the basement, your sticky notes pile up — and you won’t know which survived until you’re back at the door.
That’s the whole idea:
- Way 1 is
local-first— the device’s local database is the source of truth. - Way 2 is
server-authoritative— the server’s database is the source of truth.
Nizhal defaults to local-first, because that’s the answer that keeps working when the network
doesn’t.
Mode 1 — local-first (the default)
Section titled “Mode 1 — local-first (the default)”The promise: your local SQLite store is the truth. A write is done the moment it lands there — durably, on the device — whether or not a server exists, whether or not you’re online, whether or not you ever reload.
Walk through what actually happens when the user taps “add note”:
- The mutator runs on the device and the row is committed to local SQLite immediately (this is the real meaning of “source of truth” — the row isn’t a temporary overlay, it’s the data).
- The same write is recorded in a durable outbox (a persisted command log).
- If a server is configured, the outbox replays to it when reachable. If not, the write just… stays. It’s already real.
- Reload the page, kill the app, fly with no signal for a week — the row is still there, because it was never waiting on anyone.
You can run this mode with no server at all — server is optional. That’s a fully offline,
durable, reactive app out of the box. Add a server later and it starts syncing with zero changes to
your app code.
What syncing does here: “pull-as-merge” behind an acknowledgement barrier
Section titled “What syncing does here: “pull-as-merge” behind an acknowledgement barrier”This is the subtle part, so let’s go slowly. When a server is connected, two streams of truth meet:
your un-synced local writes, and the authoritative rows the server sends back on pull. If the server
just overwrote everything, it would erase the note you typed two seconds ago that hasn’t uploaded yet.
So local-first sync merges instead of overwrites, with one rule — the acknowledgement barrier:
The server’s version of a row replaces your local version only after the server has acknowledged your own pending write to that row. Until then, your local write wins and stays visible.
In notebook terms: when you reconcile notebooks, you don’t let your flatmate erase the line you just wrote and haven’t shown them yet. You only accept their version of a line once they’ve seen yours. The result: offline writes are never silently lost, and once the round-trip completes, everyone converges on the server’s authoritative row.
(In the code this is applyLocalFirstPullResult — a merge that can momentarily block a row’s
replacement while a local write for it is still in flight.)
Where local-first costs you something
Section titled “Where local-first costs you something”- The optimistic result the user sees is your computation, which may differ slightly from what the server finally decides (a total, a derived field). The next pull reconciles it — see Optimistic approximation for how to choose what to predict vs. mark “pending”.
- “The device is truth” means a lost/wiped device is a lost queue until it syncs. The durable outbox + idempotent replay make this safe across reloads and crashes, but the device is genuinely a first-class participant, not just a cache.
Mode 2 — server-authoritative (opt-in)
Section titled “Mode 2 — server-authoritative (opt-in)”The promise: the server is the truth. The local store is a fast cache + optimistic overlay — great for instant UI and read-while-briefly-offline, but a write isn’t “real” until the server says so.
What happens on “add note”:
- The mutator runs optimistically on the device so the UI updates instantly (the sticky note).
- The write goes to the server; on
pull, the server’s authoritative rows replace the local copy (the whiteboard wins). There’s no ack-barrier merge — pull is replacement (applyPullResult). - Because the server is the truth, this mode requires a server. Constructing a
server-authoritativecollection without one throws — there’s no whiteboard to believe.
This is how most “online app with a nice offline cache” products work. It’s the right choice when the server must be the final word and brief offline is a convenience, not a hard requirement.
Where server-authoritative costs you something
Section titled “Where server-authoritative costs you something”- Go offline for real and writes are in limbo — queued, but not “true,” and the server can still reject or reshape them on reconnect.
- No ack-barrier protection: the model is “server wins,” so you design around the server being able to overwrite the optimistic UI.
Side by side
Section titled “Side by side”local-first (default) | server-authoritative (opt-in) | |
|---|---|---|
| Source of truth | the device’s local SQLite | the server’s database |
| A write is “done” when… | it hits local SQLite (instantly) | the server accepts it |
| Needs a server? | No (optional — add to sync) | Yes (throws without one) |
| Offline writes | durable, real, survive reload | queued, provisional |
What pull does | merges behind an ack barrier (never clobbers un-acked local writes) | replaces local with server state |
| Mental model | everyone’s notebook + reconcile | one whiteboard at the door |
| Best for | offline-first, field apps, local-first products, “works on a plane” | online apps where the server must be final word |
Choosing (and how to set it)
Section titled “Choosing (and how to set it)”Default to local-first unless you have a specific reason the server must be the final authority
before a write counts. It’s the only mode that makes “works offline” true rather than best-effort.
// app-wide defaultconst echo = createNizhalClient({ mode: "local-first", // omittable — this is the default server: "https://api.example.com", // optional in local-first; required in server-authoritative bucketsForSyncRule: () => [ownerId],});
// or override per collectionnizhalCollectionOptions({ name: "ledger", syncRule: "myLedger", echo, mode: "server-authoritative", getKey: r => r.id });You can mix: most collections local-first, and a money-movement collection server-authoritative
where you’d rather the write block on the server than be optimistically wrong.
Common confusions
Section titled “Common confusions”- “local-first means no server.” No — it means the device is the truth. You usually still run a server; it just becomes a sync/replication peer rather than the gatekeeper. Running with no server is a supported special case of local-first, not its definition.
- “server-authoritative is safer.” It’s not safer, it’s different — it trades guaranteed offline durability for a guaranteed single source of truth. Money movement wants it; a notes app does not.
- “Optimistic = local-first.” Both modes are optimistic (instant UI). The difference is what
happens to that optimistic row on
pull: local-first merges and protects it; server-authoritative replaces it.
Where to go next
Section titled “Where to go next”- How sync works — cursors, tombstones, idempotency under the hood.
- Conflict resolution — what “the server’s version” actually means per row.
- Bring your own backend — the optimistic full/light/pending spectrum.