Offline Mode
Opt in, and the SDK keeps a local copy of the data this browser has seen in IndexedDB, answers reads from it when the server cannot be reached, and queues writes made offline to replay in order when the connection is back. It is the data half of working offline; the page cache is the files half, and it is on by default.
const client = await createClient({ offline: { enabled: true } });
// or, with a URL: createClient(url, { offline: { enabled: true, maxBytes: 50 * 1024 * 1024 } })
// Reads answered from the cache say so
const r = await client.get(["notes", "n1"]);
if (r.fromCache) showStaleHint(r.cachedAt);
// Writes made offline are accepted locally and replay later
const w = await client.set(["notes", "n1"], { text: "written on the train" });
if (w.status === "Queued") showPendingBadge();
client.offline.onSync((e) => {
if (e.type === "failed") toast("A change could not be saved: " + JSON.stringify(e.op.path));
if (e.type === "drained") hidePendingBadge();
});Reads
While the socket is open, get and query go to the server exactly as before, and the reply refreshes the cache on its way back. A promise resolves once, and a stale answer on a realtime database is a bug, so online reads are never served from the cache. When the socket is down — or dies with the request in flight — the SDK answers from the cache if it can, and the reply carries fromCache: true and cachedAt (the time the cached copy was fetched). When it cannot, the reply is { status: "Offline" }, as it always was.
The cache is one tree, in the same shape the engine keeps on disk, plus a coverage set: the paths that were read in full. A path is answerable offline when it, or any ancestor, is covered. What covers a path:
- a
getof it (including one that answered"Path not found"— the cache then knows it is absent); - the first payload or any push of a
getSubon it; - a
setorremoveof it that the server accepted, or that is queued; - a
queryorquerySubresult covers each matched record, not the collection. So after only a query, a read of one matched record works offline and a read of the whole collection does not.
An update or increment covers its path only when nothing existed there before (then the value is entirely yours). getSubChanged and querySubChanged pushes merge into the tree but cover nothing, because a delta is not a full value.
Queries offline
The server's filter grammar (comparisons, && || !, .includes(), child.field.path) and its ordering rules (type rank, then value, then key; integer-looking keys before others) are implemented in the SDK, so orderBy, desc, limit, startAt and count behave the same offline, over the records the cache holds. An offline query reply carries plan: "cache" and partial: true when the collection itself is not covered, meaning records the browser never fetched cannot be in the result. cursor queries are online-only (a cursor is a position in the server's index) and answer "Offline".
Subscriptions
A subscription made with offline mode on fires once from the cache as soon as it is made, with fromCache: true on the payload, and then exactly as before: the server's first payload when the socket is open, every push after that. The screen has content while a slow socket catches up. The cached payload has the same shape as the server's, including the echoed window fields on querySub. A subscription with nothing cached under it simply waits for the server. Show the flag: a quiet "updating" state keeps values from changing under the user with no explanation.
A queued write also fires the subscriptions on its path and every ancestor, locally, with fromCache: true — the engine's bubble-up, without the engine. Deeper subscriptions are not fired, which is also the engine's rule.
Writes
With the socket open and nothing queued, a write goes straight to the server and the local tree is updated only when the server says "Success". Otherwise — offline, or while a replay is in progress, or when the socket died with the write in flight — the write is applied to the local tree at once, stored in IndexedDB, and resolved { status: "Queued", action, path, opId }. A queued increment carries the locally computed result in data, and the engine's checks (a finite number or an object of them, no more than 256 fields, never against a non-number) run locally so an increment the server would refuse resolves "Failed" right away instead of queueing.
On the next open the queue replays in order, before the subscriptions are restored, so the first server values the app sees already include its own writes. Each op on the wire carries its opId and the browser's clientId; the engine remembers the ops it applied and answers a replay from memory without applying it again. That is what makes a lost reply safe: an increment whose acknowledgement never arrived is sent again and lands exactly once. The durable flag replays with the op; a queued durable write resolves "Queued", never "Success".
What happens to each op at replay:
| Server answer | What the SDK does |
|---|---|
"Success" | Removes the op. A replayed increment takes the server's resolved counters. Emits sent. |
"Failed" (a rule refused it) | Removes the op for good, drops coverage beneath and above its path so the next online read refetches the truth, emits failed with the op and the reply. The optimistic value is visible until then; the event is where the app tells the user. |
| none — the network went again | Keeps the op at the head of the queue and stops. The next open starts here. |
Conflicts are last-writer-wins, and replays are judged as whoever is signed in at reconnect. A queued set replays over whatever the server has by then, and the server's rules see the replay as the cookies' current user. That is right for a person's own data — notes, a counter, a draft, a personal list — and wrong for anything two people contend for. A booking slot that must never double up, queued offline and replayed an hour later, either double-books or is refused by a validate rule long after the user thought they were done. Keep such writes online-only: check client.online before sending, or leave offline mode off for that app.
One cache per browser, wiped on sign-out
The cache is not namespaced by user. It is emptied — tree, coverage and queue — by logout(), by client.offline.clear(), and when the server closes the socket with code 4001, which it does when a session is revoked or expires while connected. login() and createUser() empty the tree and coverage but keep queued writes, which then replay as the new user. A session that expires while the tab is closed sends no 4001, so on a shared device the next person can see cached values until the server replaces them; it is data that was already on that device, and their own writes are judged by the server as them.
API
| Member | Does |
|---|---|
createClient(url, { offline }) | { enabled: true, maxBytes }. maxBytes (default 50 MB) caps what is written to IndexedDB: a top-level key that would exceed it is kept in memory for the session and a warning is logged once. With offline on, createClient resolves once the cache is loaded and the socket has opened, failed, or had one second — so a cached UI paints without waiting on a dead network. |
client.offline.enabled | true when offline mode is on. The object exists either way, so app code can call it without checking. |
client.offline.status() | { enabled, online, coveredPaths, queued, lastSyncAt, clientId } |
client.offline.onSync(fn) | fn({ type, op, reply }) with type one of queued, sent, failed, drained. Returns a function that stops listening. |
client.offline.flush() | Replays the queue now, if online. Resolves when the replay stops. Happens by itself on every open. |
client.offline.save() | Writes the in-memory tree to IndexedDB now. Normally it is written on a 500 ms debounce, when the page is hidden, and with every queued write. |
client.offline.clear() | Empties the cache and the queue. Unsent writes are lost. |
client.close() | Closes the socket and stops reconnecting, for good, after writing the cache out. For tearing a client down; a page that navigates away does not need it. |
Storage is one IndexedDB database per origin, nukebase-offline, with stores tree (one record per top-level key, the subtree as it is on the engine's disk), coverage, meta and outbox. A schema change wipes it rather than migrating; nothing in it is the source of truth. The SDK also exports createMemoryStorage(), the adapter the tests use to run offline mode in Node.
Offline mode: troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
A read offline answers "Offline" although the data was on screen | It came from a query or a getSubChanged, which cover records, not collections or deltas. | Read the record itself, or get the collection once while online. |
| A value changed by itself after reconnecting | A queued write was refused by a rule at replay, or another client wrote in between. | Handle the failed sync event; show fromCache values as provisional. |
| A counter went up by two | The engine is older than 1.7.0 and does not honour opId. | nukebase update. |
| Another person's data showed briefly on a shared device | The previous session expired while the tab was closed, so no 4001 arrived. | Call client.offline.clear() on your own sign-out path, or leave offline mode off for shared-device apps. |
| "would take the cache past N bytes" in the console | One top-level key is bigger than maxBytes. | Raise maxBytes, or read narrower paths so less lands under one key. |