How Sync Works
Look under the hood at subscriptions, local replicas, tiers and merges.Traditional apps send queries to a remote server, which queries a database and returns data.
This creates a few familiar problems:
- the data is immediately stale
- both the client and the server must be online
- your app's performance depends on the speed of each network hop
Techniques like websockets, client-side caching, and optimistic updates help, but they add complexity without changing the basic request/response shape.
Jazz solves these problems with query subscriptions backed by a local replica.
Queries drive everything
When your app subscribes to a query (say, all todos where
done = false), Jazz sends that query subscription upstream.
The server evaluates the query against its own current relational state, finds the matching rows,
and sends them back. The client only ever sees rows it has asked for (and
has permission to read).
The set of active queries on a client defines the rows it can see and will keep receiving updates for.
The server keeps you subscribed
When you register a query subscription with the server, it remembers.
The server keeps that subscription live. When a write, a replayed change, or a schema/policy change could affect its result, the server re-evaluates it and pushes only the rows that changed.
That means:
- if a new row matches your query, it is pushed automatically
- if a matching row changes and no longer fits, it stops appearing in the subscription result
- clients update their local replicas from deltas instead of from full snapshots every time
What happens offline
Reads always come from local storage.
In the browser, Jazz uses a dedicated SharedWorker reading from data stored locally in
IndexedDB. Multiple tabs connect directly to that one durable runtime through message ports; there
is no tab leader election.
Even without a network connection, your app can still read data it already has locally, whether that data was synced earlier or written locally on the device.
Writes behave the same way: they are applied locally immediately so the UI updates without waiting on a round-trip. Jazz queues the corresponding row-version updates for upstream sync. When the client reconnects, queued writes are sent and active query subscriptions are replayed automatically.
Infrastructure tiers
Jazz connects each client to Core, the server that authorizes and durably accepts writes.
Click New colour on any client to set a new colour for the row. It syncs to Core, which accepts the write and sends it to the other subscribed clients. If two clients write at once, the most recent write wins, and every node ends up showing the same colour.
Local is the client's copy of the data. In browser persistent mode, a worker owns that copy in IndexedDB. React Native uses a local native relay. These relays provide local persistence and connectivity; they do not replace Core's permission checks.
Core owns the authoritative database. A global write confirmation means Core
has accepted and durably stored the write.
How data flows
app -> local persistence -> Core
A write first updates the app's local state. Jazz uploads it to Core, which checks
permissions and either accepts or rejects it. Waiting for local confirms local
persistence; waiting for global also requires Core acceptance. Neither choice
changes whether the write is uploaded.
Reads sync on demand. An active query asks Core for the row versions needed to evaluate it, subject to the account's permissions. Core sends changes while the subscription remains active. The client evaluates queries locally using the data it has received.
Consistency model
Every write produces a new row version. Clients may temporarily disagree:
- one client has an optimistic write that Core has not accepted yet;
- another client has not received Core's latest update;
- two clients have concurrent local writes.
Core settles writes and sends the authorized updates needed by each subscription. Local-first reads can use cached data and pending edits immediately. Strict remote reads wait for Core-authorized query coverage and exclude pending local edits.
When clients write concurrently to the same field of the same row, Core decides the current visible result field by field, based on what each writer had seen. Every write records the version of the row it was made over. A field takes the new value unless another write changed that field since that version; then the value Core already accepted stays. Edits that lose remain in row history, so the system keeps enough information to show what was overridden and to support richer history-aware behaviour later.
Two edits conflict only when both change the same field and neither writer had seen the other's edit. Core accepts writes one at a time, in the order it receives them, so the edit it receives first wins and the other is kept in history as a lost edit. Device clocks play no part: an edit made after seeing a value always replaces it, even if the device's clock is behind.
Every replica receives Core's result, so all replicas converge on the same row.
Columns with a merge strategy combine concurrent edits instead of picking one: counters add up, grow-only sets union, and text merge columns merge the edits inside the string.
A string column declared with .merge("text"), .merge("markdown") or .merge("html") still
stores a plain string. Your app writes the whole value, as an edit of the value its editor last
showed (editedFrom): if a newer merged value reached the device first, the device merges the edit
over it before sending, so the keystroke does not undo it. Concurrent writes are merged only by
Core. Until Core's result arrives, a client shows its own edit, then switches to the merged value.
When Core receives an edit to such a column, it checks whether someone else changed that column since the version the writer saw. If not (the usual case), the edit is applied as is, with no extra work. If so, Core compares both edits with the version the writer started from, and applies both wherever they touch different parts of the text. An edit both people made applies once. Two insertions at the same spot both stay, the earlier one first. A long offline session merges the same way: each edit is compared with what that device had seen, including its own earlier edits.
When someone moves a paragraph, list item or sentence (cuts it and pastes it elsewhere) while someone else edits inside it, the edit moves with it. In HTML columns the merge never leaves an unmatched tag behind.
When both people type into the same word, both keep their letters: the word is merged character by character (in scripts that join letters with vowel signs, such as Devanagari, the word merges whole, as below).
Where both people replaced the same words, Core cannot keep both. The edit it receives later wins that part of the text. Note that this is the opposite of an ordinary field, where the first edit Core receives wins. The rest of the text still merges, and the other person's version stays in the row's history.
A merge has a fixed amount of work it may spend. A very large value that both sides rewrote heavily (for example a pasted replacement for a whole long document while someone else edits it) can exceed it. Then Core does not merge: the later edit's whole value wins, as for overlapping replacements. The limit is generous: a 1 MB document with thousands of scattered edits on each side merges well within it.
An edit made on top of an edit Core rejected is rejected too, so a merge never brings back a rejected change.
See it in action
The examples reference shows complete applications that exercise query subscriptions, real-time sync, and conflict-friendly collaboration.