Offline-first

Persist reads and writes to disk so the app boots, renders, and accepts edits with no network.

Last updated:

Lunora's transport is online-first by default: reads hydrate from a live WebSocket and writes go straight to your Worker. Two opt-in client options turn that into an offline-first experience, where the app boots from disk, renders cached reads before a socket opens, and accepts writes while disconnected:

  • queryCache — a durable read cache. Query results are persisted as their subscriptions advance and replayed on construction, so a reload renders the last-seen data immediately, then resumes the live subscription from the persisted cursor (no full snapshot refetch).
  • persistence — a durable store for the offline mutation outbox. Mutations issued while disconnected survive a reload and flush, exactly once, on reconnect.

Both are off by default; current behaviour is unchanged until you pass an adapter.

Enable persistent reads and writes

Pass IndexedDB adapters for both stores when you construct the client:

import { LunoraClient, createIndexedDbPersistence, createIndexedDbQueryCache } from "lunorash/client";

const client = new LunoraClient({
    url: import.meta.env.VITE_LUNORA_URL,
    // Durable outbox: queued mutations survive a reload.
    persistence: createIndexedDbPersistence(),
    // Durable read cache: queries hydrate from disk on boot.
    queryCache: createIndexedDbQueryCache(),
});

Both adapters share a single lunora IndexedDB database, so there is nothing else to wire up. For tests or SSR you can swap in the in-memory variants (createInMemoryPersistence() / createInMemoryQueryCache()), which implement the same contract without touching IndexedDB.

In React, build the client once and hand it to the provider:

import { LunoraProvider } from "@lunora/react";
import { useState } from "react";

export const Providers = ({ children }: { children: React.ReactNode }) => {
    const [client] = useState(
        () =>
            new LunoraClient({
                url: import.meta.env.VITE_LUNORA_URL,
                persistence: createIndexedDbPersistence(),
                queryCache: createIndexedDbQueryCache(),
            }),
    );

    return <LunoraProvider client={client}>{children}</LunoraProvider>;
};

What gets persisted

Each cached query stores { identity, value, serverCursor, ts }, keyed by functionPath::argsKey::shardKey. The cache is LRU-capped by ts (oldest entries evicted first) and written with a short debounce as values advance, so a chatty subscription doesn't thrash the disk.

The identity field is a fingerprint of the auth identity at write time. On boot the client only hydrates entries whose identity matches the current one. A signed-out cache never leaks into a new session, and an identity change clears the cache outright. The offline outbox is gated by the same rule, so queued writes are never replayed under a different user.

By default the fingerprint is a hash of the bearer token — which means a token refresh (same user, new JWT) reads as an identity change and would discard queued writes. Pass a stable subject (the user id) so a refresh during an offline window keeps them:

client.setAuthToken(accessToken, user.id); // identity keyed on user.id, not the token bytes

The subject is sticky: a later setAuthToken(refreshedToken) that omits it keeps the established subject, and establishing the subject for the first time on an unchanged token (e.g. the user id resolves a tick after the token was set) re-stamps in-flight queued writes rather than dropping them. A real user switch (the token and subject both change) still drops the previous user's writes; pass null to clear the subject on an explicit sign-out. Prefer passing a stable id consistently — avoid a user?.id that's transiently undefined across a reload boundary, since a value queued under the token-hash and replayed under the subject after a reload can still mismatch.

Surviving a breaking deploy

Set persistenceVersion to invalidate persisted writes and cached reads across a breaking change to a function signature or query shape. Bump it on deploy; on the next boot, any persisted write or cached read stamped with a different version is dropped (and purged) instead of replayed/hydrated against the new schema:

new LunoraClient({ url, persistence: createIndexedDbPersistence(), persistenceVersion: "2024-06-30" });

Omit it to disable version gating (records are never invalidated by version). Adopting it is itself an invalidation event: records written before you set persistenceVersion carry no version, so the first boot after enabling it purges all currently-queued offline writes (and cached reads). Enable it on a build where that clean slate is acceptable — typically the same breaking deploy you're protecting against — not purely speculatively.

Multiple tabs

The durable outbox is shared across a profile's tabs. To avoid every tab re-queuing and replaying the same persisted writes on reconnect, the standalone client elects a single leader (via the Web Locks API) that owns hydration; when the leader tab closes, another takes over. Server-side idempotency keeps replays exactly-once regardless, so this is an efficiency guard, not a correctness one. (Where Web Locks are unavailable — React Native, SSR — a single context hydrates directly.) One trade-off: because only the leader hydrates the pre-existing persisted queue, writes left over from a prior session can sit until the leader flushes them — so if the leader is a backgrounded/offline tab while a foreground tab is online, those carried-over writes wait for the leader (the writes stay durable and replay exactly-once; only their latency is affected). New writes made in any tab flush over that tab's own socket. Apps with heavy multi-tab offline use should prefer @lunora/db collections, whose outbox is fully leader-coordinated.

Sync status

Beyond connection status, surface how many writes are waiting to sync:

client.pendingCount(); // number of queued offline writes not yet sent
const off = client.onPendingChange((n) => setBadge(n === 0 ? "Synced" : `Syncing ${n}…`));

A @lunora/db app reads db.pendingCount() (its writes ride the unified outbox, not the built-in queue).

Connection status UI

Show the user when they're working offline. Every framework adapter exposes the client's aggregate socket status (idleconnectingconnectedoffline), reading the current value synchronously and updating on every transition:

// React
import { useConnectionStatus } from "@lunora/react";

const SyncBadge = () => {
    const status = useConnectionStatus();
    return <span data-status={status}>{status === "connected" ? "Live" : "Offline"}</span>;
};
<!-- Vue -->
<script setup lang="ts">
import { useConnectionStatus } from "@lunora/vue";

const status = useConnectionStatus();
</script>

<template>
    <span :data-status="status">{{ status === "connected" ? "Live" : "Offline" }}</span>
</template>
// Solid
import { createConnectionStatus } from "@lunora/solid";

const SyncBadge = () => {
    const status = createConnectionStatus();
    return <span data-status={status()}>{status() === "connected" ? "Live" : "Offline"}</span>;
};
<!-- Svelte -->
<script lang="ts">
    import { connectionStatus } from "@lunora/svelte";

    const status = connectionStatus();
</script>

<span data-status={$status}>{$status === "connected" ? "Live" : "Offline"}</span>

Boot with no network (the app shell)

The client renders cached reads on boot, but the browser still has to fetch your HTML, JS, and CSS. To open the app cold while offline, cache the app shell with a service worker. A minimal cache-first shell:

// sw.ts
const SHELL = "lunora-shell-v1";
const ASSETS = ["/", "/index.html", "/assets/app.js", "/assets/app.css"];

self.addEventListener("install", (event: ExtendableEvent) => {
    event.waitUntil(caches.open(SHELL).then((cache) => cache.addAll(ASSETS)));
});

self.addEventListener("fetch", (event: FetchEvent) => {
    // Never cache the WebSocket or RPC — only the static shell.
    const url = new URL(event.request.url);
    if (url.pathname.startsWith("/_lunora")) return;

    event.respondWith(caches.match(event.request).then((hit) => hit ?? fetch(event.request)));
});

With the shell cached and queryCache enabled, a cold offline launch paints the last-seen data instead of a blank page. Leave the Lunora transport paths (/_lunora/*, the WebSocket) out of the service worker. Lunora owns its own reconnect, replay, and read-your-writes semantics, and caching them would fight it.

The reconciliation model

When the socket comes back, Lunora reconciles disk state with the server without you writing sync glue:

  1. Reads resume from the cursor. The client sends the persisted serverCursor as sinceSeq. The server replays only the deltas you missed (or a resume ack when nothing in your read-set changed) instead of a full snapshot, so the cached value you already rendered stays on screen and is patched forward.
  2. Writes flush exactly once. Queued mutations are deduped by mutationKey and replayed on reconnect; the idempotency key means a retry after a flaky ack is a no-op server-side, not a duplicate.
  3. Optimistic patches rebase, then settle gaplessly. A per-call optimistic transform is recorded as a layer on the query, not applied once-and-forgotten. An unrelated server delta that lands while the write is still pending (queued offline, or in flight) is re-folded under the layer instead of clobbering it, so your change never flickers away and back. The layer is dropped — with no double-count and no flicker — the moment a frame whose cursor reaches the write's committed cursor arrives (the server echoes that cursor on the mutation response), so the drop is keyed on server-confirmed state rather than RPC-response timing, which races the WebSocket broadcast. A coded rejection rolls the layer back.
  4. Identity is the trust boundary. A token change between sessions drops both the cached reads and the queued writes rather than replaying them under the new identity.

Surfacing rejected writes

A queued write that the server ultimately rejects (a coded conflict, a validation or RLS denial) rolls its optimistic row back. For a write you await directly, that surfaces as the rejected mutation() Promise. But a write queued in one session and replayed in the next — after a reload — has no awaiter left, and the same is true for a write the queue evicts on overflow or discards on an identity change. Without a signal, the optimistic row would simply vanish, which users read as data loss.

onMutationSettled is the durable channel for exactly this. It fires once per queued write that reaches a terminal verdict — committed or rejected — and includes replays whose original Promise is gone (hadAwaiter: false):

client.onMutationSettled((event) => {
    if (event.status === "rejected" && !event.hadAwaiter) {
        // A queued change couldn't be saved and no caller is awaiting it —
        // tell the user instead of silently dropping the rolled-back row.
        toast.error(`Couldn't save your change (${event.code ?? "error"}).`);
    }
});

event carries the functionPath, args, shardKey, and — on rejection — the code (e.g. CONFLICT, OFFLINE_QUEUE_OVERFLOW, OFFLINE_IDENTITY_CHANGED) and error. Causally dependent writes that the server rejects in turn each surface their own event, in FIFO order. The @lunora/db collection layer exposes the same idea as onWriteRejected.

See also

  • @lunora/db — the same outbox + optimistic model as a TanStack DB collection layer, generated from your schema.
  • Real-time — how subscriptions and deltas flow.
  • @lunora/client — the transport these options configure.