Offline and sync

Keep a grid's records on the device, keep working without a network, and sync through two endpoints.

An offline grid keeps its records in the browser (IndexedDB), opens instantly with the data the user saw last, keeps working without a network, and never loses an edit: every write is queued until your server acknowledges it. You write two endpoints; the grid does the storage, queueing, retries and merging.

It is the data option's device copy (data.offline), beside the rules and saving that every grid has (Rules and saving). The device store and httpSync live in their own entry point: a grid that does not import it ships none of it.

Local-only

Records persist on this device; nothing is sent anywhere. A personal tracker, or a field app before it has a backend.

import { Grid } from '@witbybit/wit-grid-react';
import { offline } from '@witbybit/wit-grid-react/offline';

type Deal = { id: string; name: string; amount: number };

function Deals({ userId, initialDeals }: { userId: string; initialDeals: Deal[] }) {
	return (
		<Grid<Deal>
			rows={initialDeals}
			getRowId={(deal) => deal.id}
			columns={[
				{ field: 'name', header: 'Name' },
				{ field: 'amount', header: 'Amount' },
			]}
			data={{ offline: offline({ name: `deals:${userId}` }) }}
		/>
	);
}

Synced with your server

import { Grid } from '@witbybit/wit-grid-react';
import { offline, httpSync } from '@witbybit/wit-grid-react/offline';

type Deal = { id: string; name: string; amount: number };

function Deals({ accountId, getToken }: { accountId: string; getToken: () => string }) {
	return (
		<Grid<Deal>
			getRowId={(deal) => deal.id}
			columns={[
				{ field: 'name', header: 'Name' },
				{ field: 'amount', header: 'Amount' },
			]}
			data={{
				offline: offline({ name: `deals:${accountId}` }),
				save: httpSync({ url: '/api/deals/sync', headers: () => ({ Authorization: `Bearer ${getToken()}` }) }),
			}}
		/>
	);
}

headers is passed through to each request as given; the grid never reads or stores credentials. To use your own API client instead of httpSync, pass any object with push and pull as save:

const sync = {
	push: (changes, signal) => api.post('/deals/push', { changes }, { signal }),
	pull: (cursor, signal) => api.get('/deals/pull', { params: { cursor }, signal }),
};

Who owns the records

With offline on, the grid owns its records on the device. rows is seed data: used once, when the replica is empty (the first visit, or after api.data.clearDevice()), and stored as the server's data, so seeded rows are never sent back as new ones. Later changes to the rows prop are ignored (with a development warning). Server data comes in through pull; api.setRows() and api.transaction() are local edits and are queued like any other.

Without rows and with an empty replica, the grid shows its loading state until the first pull lands (its empty state when offline).

Use client-generated ids (crypto.randomUUID()) for rows created on the device: the server keeps the id it is given.

How it works

What is stored on the device

The grid keeps all of its records, not just the ones you edited, in one IndexedDB database per name, called wit-grid:<name> (look for it in your browser's devtools under Application → IndexedDB). Inside it:

StoreHolds
chunksthe records, about 1,000 per entry, in the grid's row order, with each row's server version
outboxthe edits not yet confirmed by your server, in order
metathe pull cursor, unresolved conflicts, the device's id and the storage format version

Computed values (formulas, value getters, lookups) are not stored: they are worked out from the records, as always.

Opening the grid

  1. The grid shows its loading state.
  2. It reads the stored records and puts the queued edits back on top of them, so the user sees exactly what they had, including anything not yet saved.
  3. If nothing is stored yet (first visit, or after api.data.clearDevice()): with rows, those rows are stored as the server's data; without them, the grid stays in its loading state until the first pull brings everything (or shows its empty state when offline).
  4. The first sync starts.

Editing

Every edit (typing, paste, fill, undo, api.transaction, api.setRows) is queued the moment it is made, and stored within the same frame. About 250 ms after an edit the grid sends what is queued. Edits to the same row merge while they wait, so a long offline session sends what changed, not every keystroke.

Syncing

A sync run is always the same two steps: push the queued edits in order (up to 200 per request), then pull the server's changes since the device's cursor (page after page while the server says more). Pulled changes are applied in slices of 2,000 rows so a large catch-up never freezes the page; the sync state reports progress meanwhile. The records on the device are rewritten about a second after they change, in the background.

Options

offline({
	name: `deals:${accountId}`, // required: one replica per name (include the account)
	sync, // the adapter (httpSync, or your own); without it the grid is local-only
	resolve, // how a field changed on both sides is settled (default: the user decides)
	pollIntervalMs: 30_000, // pull this often while the tab is visible; 0 turns the timer off
	pushBatchSize: 200, // changes per push request
	sliceRows: 2_000, // server rows applied per frame during a catch-up
	chunkRows: 1_000, // records per stored chunk
	maxOutbox: 50_000, // past this many queued changes, a warning fault (nothing is dropped)
	store, // where the replica lives (default IndexedDB); for an encrypting store of your own
});

The server contract

Your server needs to do two things; everything else is optional. Any database can do both.

  1. Remember which changes it has applied. Every change has an id that never changes, even when the device sends it again. Retries are normal (a lost response, a closed laptop): if you have seen the id before, answer the same way again and do not apply it twice. A table of applied ids is enough.
  2. Keep a log of changes with a cursor. pull asks for "everything after this point". An append-only list with an increasing number is enough; the cursor is that number as a string.

Optional: check for conflicts. Each updated field carries base, the value the device last saw. If your current value differs from both base and the new value, someone else changed it meanwhile: reject the change with code: 'conflict' and the current row, and the user decides. If you skip this, the last write wins on the server; the grid still notices the other person's change when it pulls, keeps the user's edit, and shows the conflict.

Requests

httpSync makes these requests. A custom sync adapter calls your own API and returns the same shapes.

POST {url}/push with { "protocol": 1, "changes": [...] }, applied in that order. Each change:

{
	"id": "6f1c…:42",
	"at": 1760000000000,
	"added": [{ "id": "d9", "data": { "id": "d9", "name": "Hooli", "amount": 22000 } }],
	"updated": [{ "id": "d2", "fields": { "amount": { "base": 200, "value": 250 } }, "baseVersion": 17 }],
	"removed": [{ "id": "d4", "baseVersion": 9 }]
}

Answer { "accepted": [{ "id", "versions"? }], "rejected": [{ "id", "code", "reason", "current"? }] }. versions maps row ids to their new version (any string or number). code is validation, permission, conflict, not-found or other; a rejected change is rolled back on the device, and current (rows as you have them) is shown instead.

GET {url}/pull?protocol=1&cursor=… returns { "changes": [...], "cursor": "…", "more"?: true }. Without a cursor: everything, as [{ "upserted": [rows] }]. With one: the log after it, a page at a time (more when there is more). Each entry is { "upserted"?: [{ id, data, version? }], "patched"?: [{ id, fields, version? }], "removed"?: [{ id, version? }] }. Return "reset": true with a full snapshot when a cursor is too old to serve.

GET {url}/events (optional): a Server-Sent Events stream; any message tells the grid to pull now. Without it the grid pulls on reconnect, when the tab is shown, and every 30 seconds while visible.

401 or 403 keeps the queue waiting until api.data.save() (after the user signs in again).

Compatibility

protocol is the protocol's version. Refuse a version you do not know. Both sides ignore fields they do not recognise: a new optional field can appear in any release, and any other change is a new protocol version.

What happens to edits

  • Every write path (typing, paste, fill, undo, api.transaction) is queued. Edits to the same row merge while unsent: typing a → b → c sends one change, and changing a value back sends nothing.
  • Cells not yet acknowledged are marked; their tooltip says "Not saved yet".
  • Server changes are applied with your pending edits kept. A field changed only on the server shows the server's value; one changed on both sides with different values becomes a conflict: the server's value is shown, yours is kept, and the cell is marked "Changed on the server too".
  • A rejected change is rolled back exactly, and its fields keep the server's reason. api.data.save() returns the refusals (status: 'rejected').
  • With data.rules, a record that breaks a blocking rule holds every queued change back until it is fixed.
api.getStatus().data; // { state, changes, conflicts, issues, blocking, lastSavedAt, lastError, ready, device }
const [conflict] = api.getStatus().data.conflicts.filter((c) => c.source === 'sync'); // the ones sync raised
await api.data.resolve(conflict.id, 'local'); // or 'remote', or { value }
api.data.discard(); // rolls back what is not saved yet
const result = await api.data.save(); // saved | nothing | blocked | rejected | failed

To resolve conflicts without asking the user, pass a policy. It receives each conflict (row, column, base, yours, the server's), so the rule can differ per column, and 'ask' leaves that one for the user:

const save = {
	...httpSync({ url: '/api/deals/sync' }),
	resolve: (conflict) => {
		if (conflict.colField === 'notes') return 'local'; // this device's notes win
		if (conflict.colField === 'status') return 'remote'; // the server's workflow wins
		return 'ask'; // everything else: the user decides
	},
};

Returning { value } sets a value of your own (a merge you compute). A row removed on the server while it was edited here arrives with colField: '*'; 'local' restores the row with the local edits.

Keeping the data fresh

There is no expiry: stored rows are never thrown away because they are old. They stay current because the grid asks your server for everything that changed since its cursor and applies exactly that.

When the grid pullsHow
Right after openingthe first sync run
After every pusheach sync run pushes, then pulls
The browser comes back onlinethe online event
The tab is shown againswitching back to the tab
On a timerevery pollIntervalMs (30 s) while the tab is visible; it restarts after each successful sync
Your server says soany message on GET {url}/events (or your adapter's subscribe)
Your code says soawait api.data.save()

How the stored rows change:

  • A row changed on the server arrives as upserted or patched and replaces the stored values, except fields with an unsaved local edit: those keep the local value (or become a conflict if both changed).
  • A row deleted on the server arrives in removed and leaves the device. If it had unsaved edits, it becomes a conflict with colField: '*' in getStatus().data.conflicts: resolving it with 'local' restores the row with those edits, 'remote' accepts the deletion.
  • A new row on the server arrives as upserted and is added.
  • Column changes in your app do not affect the stored rows: records are stored as data, so a new column simply reads from them.
  • The rows prop is not a refresh: after the first visit it is ignored (with a development warning when it changes). Server data comes in only through pull.

Forcing a full refresh

  • From the server: answer a pull with "reset": true and a full snapshot. The device takes the snapshot as the truth: rows not in it are removed, except rows added on this device that have not synced yet, and unsaved edits are kept on top. Do this when a cursor is too old for your log, or when every device should start over (after a data migration, a restore from backup, or a change to who can see which rows).
  • From the app: await api.data.clearDevice(), then remount the grid. The device's copy is deleted and the next grid pulls everything. Unsaved edits are discarded, so check api.getStatus().data.changes first.

Showing sync state

With statusBar, the status bar shows the sync state ("Saved", "Offline · 3 not saved yet", "Sign in to sync") and announces changes of status to screen readers. For your own UI:

import { useGridWatch } from '@witbybit/wit-grid-react';

function SaveBadge() {
	const data = useGridWatch((grid) => grid.status.data);
	const unsaved = data.changes.length;
	return <span>{data.state === 'offline' ? `Offline · ${unsaved} unsaved` : unsaved ? 'Saving…' : 'Saved'}</span>;
}

The words are the grid's, so they translate with the rest of it: createGrid({ messages: { sync: { saved: 'Gespeichert' } } }) (or <Grid messages={…}>).

Signing out

const unsaved = api.getStatus().data.changes.length; // warn the user first if > 0
await api.data.clearDevice(); // wipes this device's replica; resolves with the number of changes discarded

After the wipe the grid keeps showing what it had but stores nothing more on the device (its sync state becomes unavailable), so the signed-out user's data is never written back. Destroy or remount the grid when the user signs out; the next grid opens an empty replica and pulls the next user's data.

Troubleshooting

Start with the sync state and the grid's faults; between them they say what is wrong.

api.getStatus().data; // state, changes, conflicts, lastError, lastSavedAt, device (leader, persisted, progress), ready
api.getRuntimeFaults().filter((fault) => fault.source === 'offline'); // why offline is not running, storage errors
console.log(await api.data.save()); // what the server refused, with its reasons

Faults are also logged to the console as [WitGrid runtime fault] offline:….

What the status means

stateMeaningWhat to do
idlein sync; changes lists the edits queued (they go out within a moment)nothing
savinga push or pull is in flightwait; a large catch-up shows device.progress
offlinethe browser reports no network; edits queuenothing: it syncs when the network is back
errorthe last sync failed; lastError.code says whysee below; it retries by itself, except for auth
unavailableoffline is not running in this grid; it works online-onlyread the offline fault (below)

lastError.code

CodeCauseWhat happens
networka request failed to reach the server (also when the browser says online but the network is down)retries after 1 s, 2 s, 4 s … up to about a minute
servera 5xx answer, or a push answer that mentions none of the changes sentretries the same way; check your server's logs
auth401 or 403waits; sign the user in again, then api.data.save()
storagethe device could not store (quota full, private mode, storage cleared)edits stay in memory and are stored again when possible; free space or check persisted
otheranything else, including a non-JSON answer or a 4xxretries; check the message

Symptoms

SymptomLikely causeCheck
Status unavailable from the startIndexedDB missing (an old browser, some private modes); another grid in this tab uses the same name; another tab has this replica open; a server-side or infinite row model; a replica written by a newer releasethe offline fault's message names which
A second tab never stores anythingone tab owns a replica; the second runs online-onlyclose the first tab, or accept it: the second tab still saves through its own requests
Edits never leave changesthe server answers without mentioning them, or the status is errorthe network tab: does /push answer with each change's id in accepted or rejected?
An edit disappears after syncingthe server refused it and it was rolled backapi.data.save() returns the code and reason
An edit is applied twice on the serverthe server does not remember applied change idsstore each change id and answer a repeat with the saved result
Other people's changes never arrivethe pull cursor does not move, or the log misses changes made outside the push endpointdoes /pull return a new cursor and the other changes? Changes made by other systems must be added to the log too
Changes arrive only after a whileno /events stream, so the grid pulls on its timer (30 s while visible)add /events, lower pollIntervalMs, or call api.data.save() after your own realtime signal
The grid shows rows the server deletedthe deletion never reached the loglog deletions as removed, or answer the next pull with reset: true
Stale data after a migration on the serverthe log does not describe the migrationanswer the next pull with reset: true and a snapshot
New rows from the app do not showrows only seeds an empty replicasend the data through the server (pull), or api.data.clearDevice() and remount
A conflict nobody causedthe server rewrote a value itself (a trigger, rounding, a default) while another edit to that field was still queuedkeep server-side rewrites out of fields users edit, or set resolve for that column
Data gone after weeks awaythe browser evicted the site's storagedevice.persisted in getStatus().data; synced data comes back from the server, unsynced edits do not
The grid stays in its loading statean empty replica waiting for the first pull, which keeps failinglastError, and the network tab for /pull

Limits

  • One tab owns a replica. A second tab on the same grid runs online-only (it saves, but keeps nothing on the device) and its sync state says unavailable. Closing the first tab frees the replica.
  • Client grids only. Server-side and infinite row models run online-only.
  • Durability window. An edit made in the same instant the tab is killed may not be stored; anything older is.
  • Not encrypted by the grid. IndexedDB is the browser profile's storage. Put the account (and tenant) in name so another user of the browser never sees the replica; pass your own store to encrypt it.
  • Eviction. Browsers can clear storage for sites not visited in a while; device.persisted in getStatus().data says whether the browser promised not to. Synced data comes back from the server; unsynced edits would not.
Edit on GitHub

Last updated on

On this page