Offline-first: IndexedDB, a queue, and honest sync

Offline-first: IndexedDB, a queue, and honest sync

A tracker lives in your pocket, and the internet doesn’t reach everywhere — the metro, the dacha, the road. So we set one hard rule for the diary: an entry lands in IndexedDB first and only then sets off for the server. Tap “Add to diary” and the food is in the local database instantly, drawn on screen right away, network or no network. Whether it ever reaches the server, and how fast, is a question for the queue — more on that below.

We settled the storage question quickly. localStorage gives you no transactions, no cursors, no structure, and a catalog of 131 products, together with the entries and the queue, would sit right up against the 5 MB ceiling. IndexedDB covers everything at once — object stores, keys, cursors for cleanup.

The steps of every mutation

Adding an entry works like this:

async function addEntry(entry, extra) {
  state.entries.set(entry.client_uuid, entry); // memory — for rendering
  await db.putEntry(entry);                    // IndexedDB — survives a reload
  await db.enqueue({ kind: 'entry_save', payload: entryPayload(entry), ...(extra || {}) });
  rememberRecentFood(entry.food_id);
  renderActiveView();
  scheduleSync(); // drain attempt
}

Every entry carries a client_uuid — a crypto.randomUUID() we generate right on the device. That uuid is the master identifier, identical in the local database, in the queue, and on the server. Editing and deleting go through the same uuid.

The queue is a separate object store in IndexedDB with an auto-incrementing seq. Order matters inside it. When the user creates a personal product, the product becomes the queue’s first task and the entry referencing it the second, so that when the drain reaches the entry and it asks for that id, the product already exists.

Draining the queue: one task at a time

The queue drains one task at a time. Send them in batches and the product-before-entry order scrambles, while error handling swells beyond recognition. Three events trigger a drain — online, a timer every 30 seconds, and, in practice, any user action at all. The heart of the loop:

const task = items[0];
try {
  await execTask(task);
  await db.removeQueueItem(task.seq);
} catch (err) {
  if (err instanceof api.ApiError) {
    if (err.code === 'OFFLINE' || err.status === 0) { failed = true; break; } // network
    if (err.status === 429) { failed = true; await sleep(Math.min(err.retryAfter || 30, 60) * 1000); continue; }
    if (err.status >= 400 && err.status < 500) { // rejected for good
      await db.removeQueueItem(task.seq); continue; // removed silently
    }
    failed = true; break; // 5xx — will retry later
  }
  failed = true; break;
}

Every request gets 8 seconds through an AbortController. A timeout or a dead network leaves the task waiting calmly in the queue. A 429 respects retry_after from the response body, capped at one minute. A 4xx is dropped on the spot with no retries — the server said no for good, and hammering it achieves nothing.

Idempotency rests on client_uuid. The server flow behind POST /api/entries/save looks up the entry by client_uuid plus owner, then updates or creates depending on what it finds. A repeated send breeds no duplicates — same uuid, same id. An automated test verified this too: a second save with different grams returned the same id, and the entry counter stayed where it was. Editing rides the same mechanism — the same client_uuid goes into the queue again.

Merge at startup, deletes through the queue

On every launch the app pulls four resources (/api/entries, /api/foods, /api/profile, /api/weights) and merges them into IndexedDB. The merge rule is simple. Local rows carrying the dirty flag always win: they haven’t been pushed yet, and the server simply doesn’t know they exist. Clean rows are compared by the updated_at marker, and only the ones that changed get written back to the database. We shielded the profile separately: until the profile_dirty flag is set, a pull leaves local edits alone.

The catalog updates through a delta request. The client stores the timestamp of its last sync and sends it as the since parameter; the server filters in the database — updated_at greater than the marker — and returns only the changed products, at most 100 per request. The client files them into IndexedDB by id. A page shorter than a hundred means the delta is exhausted. The first sync runs with the epoch marker and pulls the whole catalog the same way, page by page. After the merge the client re-reads the in-memory index and runs pruneFoods() once more.

The diary: entries appear straight from the local database (light and dark themes)

Deletes go through the queue as well. The row vanishes locally right away, and an entry_delete lands in the queue. The server answers {"ok":true} even for a uuid that doesn’t exist, so a repeated drain breaks nothing. Immediately after the delete, a toast offers Undo: the restored entry returns with the same client_uuid, and its history stays in one piece.

Deleted entries came back

The first genuinely nasty bug: an entry deleted on the server (from another device, say) came back after a reload, and the day’s totals were wrong.

The cause was in how the sync worked. For entries without local edits, the server’s answer was the source: anything missing from the server was removed from memory. That never touched the IndexedDB copy, because the deleteEntry call for the local database simply had not been written. After a reload the app read the entries from the local database, and they showed up in the list again.

The culprit was the order of operations — the uuids marked for deletion were collected after state.entries.delete(u) had already run. The second pass over the dictionary couldn’t see them anymore, and the deletion list for IndexedDB stayed empty. The fix is to collect the list before the sweep:

const staleUuids = [];
for (const [u, e] of Array.from(state.entries)) {
  if (!e.dirty && !serverUuids.has(u)) {
    state.entries.delete(u);
    staleUuids.push(u); // collect BEFORE sweeping the dictionary
  }
}
await db.putEntries(Array.from(state.entries.values()));
for (const u of staleUuids) await db.deleteEntry(u);

A related guard sits in the drain. If the queue already holds an entry_delete for a uuid, that uuid’s entry_save still goes out to the network as usual, but after a successful push the client will no longer resurrect the local row.

The second story is actually two. Junk was surfacing in search, and two different bugs led there.

The literal “null” in the list turned out to be a rendering bug. In the offline branch a null reached replaceChildren(...), and the browser printed it as text. The section’s children are now assembled into an array and passed with a spread, with no conditional tail. The same defect showed up in two more places, the norms card and the recents list, and was fixed the same way there.

The second source of junk: entries left in IndexedDB by early builds of the app. They have no name, or their ids start with local:…. pruneFoods() sweeps them out before every index rebuild — it walks the store with a cursor and deletes everything that doesn’t qualify:

const req = t.objectStore('foods').openCursor();
req.onsuccess = () => {
  const cur = req.result;
  if (!cur) return resolve();
  const f = cur.value;
  if (!f || !f.name || String(f.id || '').startsWith('local:')) cur.delete();
  cur.continue();
};

No migrations, no “please repair me” screen: every launch cleans up on its own whatever older versions left behind.

The dot, the capsule, and “Saved”

The network state is always visible, though it rarely gets in the way. While all is well, the header carries just a green dot, not one word. A drain starts, the dot pulses. Did the last attempt fail? A yellow capsule comes up reading “N not sent,” and clicking it triggers a manual push. If the network is gone entirely, the capsule reads “Offline” — or “Offline · N not sent” when the queue is non-empty.

The “Offline · 1 not sent” capsule in the app header (light and dark themes)

When the network returns, the queue drains, the capsule shows “Saved” for 2.5 seconds, and folds back into the dot. If the offline stretch ran longer than 10 seconds, a single toast announces “Pending changes sent” — exactly one per cycle, with no spam for every task.

One bit of polish remains. The indicator is a real button that keyboard focus can reach. Its aria-label follows the state (“All changes saved” / “Offline, N changes waiting to be sent” / “Syncing…”), and aria-live="polite" announces the change to screen readers. A global prefers-reduced-motion rule switches the dot’s pulse off.

The user always sees what already sits in their IndexedDB, and the server learns the rest once the network is back.

What’s next. The offline queue works. For the app to open with no network at all — server switched off, cold start, either way — we need a service worker. The next part is “The service worker that serves flows”: why even /sw.js on this platform is served by a flow, and which cache came close to burying an update.


All posts