Skip to content

Offline and Optimistic Writes

The JavaScript client keeps working when the connection drops, and can show a change before the server has confirmed it. Both are built in. This page explains what a write's answer means in each case, how to find out what happened to it later, and how to clean up when a user signs out.

What works offline

Offline mode is on by default. While the device is offline:

  • Writing one key (db.key("theme").write("dark"), map.put(id, value), map.remove(id), setItem()) is saved in the browser and sent automatically when the connection is back.
  • Adding to a list (db.list("log").add(entry)) is saved and sent the same way.
  • Reading one key (db.key("theme").read(), map.get(id)) answers from the browser's copy of the last value it saw, including changes not sent yet. A key this browser has never read or written gives OFFLINE.
  • Reading a whole map (map.entries(), map.readAll()) answers from the browser's copy of the last time it was read online, with the changes not sent yet already applied. The array has cached: true then. A map this browser has never read online gives OFFLINE.
  • Other reads of many values (list.read(), readAll() across a whole scope, a read with keys([...])) need the network. Offline they fail with NETWORK_ERROR, so tell the user they're offline rather than showing an empty list.

Online or offline, a whole-map read includes this browser's changes the server hasn't confirmed yet, each marked pending: true, so a list drawn from it never loses an item the user just added.

Turn it off with { offline: false } on getkey() (or on a sign-in) if your app should only ever work online.

What a write's answer means

The write resolves with What it means
{ success: true } The server saved it.
{ success: true, queued: true } The device is offline (or the connection dropped during the request). The change is saved in this browser and will be sent later. It isn't on the server yet.
{ success: true, optimistic: true } You asked for an optimistic write (below). The change is shown locally; the server's answer comes later.
{ success: false, error, message } The server refused it. See Error Codes.

Don't treat queued as saved: show the change as "not synced yet" until the client tells you it was sent. await db.pendingCount() says how many of this key's changes are waiting.

Changes saved offline can be sent for 8 hours after the key was opened (offlineTtl, in seconds, changes that, up to 7 days). After that they wait in the browser until the user opens the key again (with getkey() or a sign-in) on this device, and are sent then.

Knowing when a write is saved: settled

Every write's answer has a settled Promise. It resolves when the change is on the server, and rejects (with a PrefsDBError) when it never will be:

const w = await todos.put(id, todo);
w.settled
    .then(() => markSynced(id))
    .catch((err) => rollback(id, err.code));
The write settled resolves settled rejects
Saved by the server at once
Refused by the server at once, with its code
Saved offline (queued) when it's sent if it's dropped: CONFLICT (someone changed the value on the server first, or, with ifVersion, since that version) or the server's refusal
Optimistic when the server confirms it if the server refuses it (the previous value is back)

It also rejects with SIGNED_OUT if db.logout() deletes the change before it was sent. settled doesn't survive a page reload; changes saved before a reload are still sent, and db.onSyncEvent() reports them.

Optimistic writes

An optimistic write resolves at once, before the server answers, so the screen can update without waiting:

await db.map("todos").put(id, todo, { optimistic: true });

Pass { optimistic: true } to getkey() (or a sign-in) to make it the default for every write of that key. Reading the key back right away returns the new value. If the server then refuses the write, the client puts the previous value back and tells you (optimistic-rollback, below). If the connection drops instead, the write is saved and sent later, like any offline write.

Finding out what happened: db.onSyncEvent()

db.onSyncEvent(fn) calls fn(event) whenever something happens to one of this key's saved or optimistic writes. It only reports this key's writes, so a listener for one signed-in user never hears about another's. It returns a function that removes the listener; db.logout() removes them too.

db.onSyncEvent((e) => {
    if (e.phase === "pushed" || e.phase === "confirmed") markSynced(e.key);
    if (e.phase === "optimistic-rollback" || e.phase === "dropped") reloadTodos();
});
phase When
queued A write was saved in the browser to be sent later.
start, done The client starts and finishes sending saved writes.
pushed A saved write reached the server.
conflict Someone changed the same value on the server after this write was saved. The server's newer value wins; dropped follows.
dropped A saved write will never reach the server. reason is conflict (the server's newer value won) or refused (the server turned it down for good, e.g. the value is too large; result has its answer). The browser's copy is corrected, so read the value again.
sync-error The server refused a saved write for now (e.g. too many requests). It stays saved and is tried again.
auth-expired A saved write couldn't be sent (no answer). It stays saved and is tried again.
pulled A value this browser had a copy of was changed on the server.
optimistic An optimistic write was shown locally.
confirmed The server accepted an optimistic write.
optimistic-rollback The server refused an optimistic write; the previous value is back. result has the server's answer.

Each event has key, an id for the value it's about. For an optimistic list add(), it matches the tempId the add resolved with, so you can remove just that item from your list.

PrefsDB.sync() sends saved writes now (it also happens by itself when the connection comes back). PrefsDB.onSyncEvent(fn) is the same as db.onSyncEvent() but hears every key on the page.

Recipe: an optimistic to-do list

A complete pattern for a list that updates at once, works offline, and corrects itself when a change doesn't stick:

const db = await PrefsDB.getkey("todo-app", seed, { email, optimistic: true });
const todos = db.map("todos");

async function render() {
    const items = await todos.entries({ max: Infinity });  // offline: from this browser's copy
    drawList(items.map(({ key, value, pending }) => ({ id: key, ...value, unsynced: pending })));
    showOfflineBanner(items.cached === true);
}

async function save(id, todo) {
    const w = await todos.put(id, todo);            // optimistic: resolves at once
    render();                                       // already shows the change
    w.settled.then(render, (err) => {               // confirmed, or put back
        render();
        showError(err.code === "CONFLICT" ? "Changed on another device" : "Couldn't save");
    });
}

async function removeTodo(id) {
    const w = await todos.remove(id);
    render();
    w.settled.catch(render);
}

db.onSyncEvent((e) => { if (e.phase === "pulled" || e.phase === "dropped") render(); });

async function signOut() {
    if (await db.pendingCount() > 0 && !confirm("Some changes aren't saved yet. Sign out anyway?")) return;
    await db.logout();
}

render();

Store each to-do as an object with put(id, todo); entries() gives it back as an object. To see other devices' changes as they happen, and to keep two devices from overwriting each other's edits, add watch() and ifVersion: see Several Devices.

Signing out

Call await db.logout() when a user signs out. It:

  • stops using the key's session in this browser;
  • deletes what the key kept in the browser: its copy of values, changes not sent yet, and the permission to send them later;
  • removes the key's onSyncEvent() listeners.

On a shared computer this is what keeps the next person from seeing the previous user's data. Changes not sent yet are lost, so check await db.pendingCount() first and warn the user if it isn't 0. db.logout({ keepLocal: true }) keeps the browser's copy, for an app where the same person will sign straight back in. logout() works offline too.

The key itself keeps working: signing in again later returns the same key and data.

With the sign-in buttons, signin.signOut() calls logout() for you. On a device the user chose to keep signed in, logout() also signs that device out (see Staying Signed In).