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:
| Store | Holds |
|---|---|
chunks | the records, about 1,000 per entry, in the grid's row order, with each row's server version |
outbox | the edits not yet confirmed by your server, in order |
meta | the 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
- The grid shows its loading state.
- 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.
- If nothing is stored yet (first visit, or after
api.data.clearDevice()): withrows, 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). - 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.
- Remember which changes it has applied. Every change has an
idthat never changes, even when the device sends it again. Retries are normal (a lost response, a closed laptop): if you have seen theidbefore, answer the same way again and do not apply it twice. A table of applied ids is enough. - Keep a log of changes with a cursor.
pullasks 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: typinga → b → csends 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 | failedTo 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 pulls | How |
|---|---|
| Right after opening | the first sync run |
| After every push | each sync run pushes, then pulls |
| The browser comes back online | the online event |
| The tab is shown again | switching back to the tab |
| On a timer | every pollIntervalMs (30 s) while the tab is visible; it restarts after each successful sync |
| Your server says so | any message on GET {url}/events (or your adapter's subscribe) |
| Your code says so | await api.data.save() |
How the stored rows change:
- A row changed on the server arrives as
upsertedorpatchedand 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
removedand leaves the device. If it had unsaved edits, it becomes a conflict withcolField: '*'ingetStatus().data.conflicts: resolving it with'local'restores the row with those edits,'remote'accepts the deletion. - A new row on the server arrives as
upsertedand 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
rowsprop 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": trueand 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 checkapi.getStatus().data.changesfirst.
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 discardedAfter 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 reasonsFaults are also logged to the console as [WitGrid runtime fault] offline:….
What the status means
state | Meaning | What to do |
|---|---|---|
idle | in sync; changes lists the edits queued (they go out within a moment) | nothing |
saving | a push or pull is in flight | wait; a large catch-up shows device.progress |
offline | the browser reports no network; edits queue | nothing: it syncs when the network is back |
error | the last sync failed; lastError.code says why | see below; it retries by itself, except for auth |
unavailable | offline is not running in this grid; it works online-only | read the offline fault (below) |
lastError.code
| Code | Cause | What happens |
|---|---|---|
network | a 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 |
server | a 5xx answer, or a push answer that mentions none of the changes sent | retries the same way; check your server's logs |
auth | 401 or 403 | waits; sign the user in again, then api.data.save() |
storage | the 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 |
other | anything else, including a non-JSON answer or a 4xx | retries; check the message |
Symptoms
| Symptom | Likely cause | Check |
|---|---|---|
Status unavailable from the start | IndexedDB 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 release | the offline fault's message names which |
| A second tab never stores anything | one tab owns a replica; the second runs online-only | close the first tab, or accept it: the second tab still saves through its own requests |
Edits never leave changes | the server answers without mentioning them, or the status is error | the network tab: does /push answer with each change's id in accepted or rejected? |
| An edit disappears after syncing | the server refused it and it was rolled back | api.data.save() returns the code and reason |
| An edit is applied twice on the server | the server does not remember applied change ids | store each change id and answer a repeat with the saved result |
| Other people's changes never arrive | the pull cursor does not move, or the log misses changes made outside the push endpoint | does /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 while | no /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 deleted | the deletion never reached the log | log deletions as removed, or answer the next pull with reset: true |
| Stale data after a migration on the server | the log does not describe the migration | answer the next pull with reset: true and a snapshot |
New rows from the app do not show | rows only seeds an empty replica | send the data through the server (pull), or api.data.clearDevice() and remount |
| A conflict nobody caused | the server rewrote a value itself (a trigger, rounding, a default) while another edit to that field was still queued | keep server-side rewrites out of fields users edit, or set resolve for that column |
| Data gone after weeks away | the browser evicted the site's storage | device.persisted in getStatus().data; synced data comes back from the server, unsynced edits do not |
| The grid stays in its loading state | an empty replica waiting for the first pull, which keeps failing | lastError, 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
nameso another user of the browser never sees the replica; pass your ownstoreto encrypt it. - Eviction. Browsers can clear storage for sites not visited in a while;
device.persistedingetStatus().datasays whether the browser promised not to. Synced data comes back from the server; unsynced edits would not.
Last updated on