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 token at write time. On boot
the client only hydrates entries whose identity matches the current token.
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.
Connection status UI
Show the user when they're working offline. Every framework adapter exposes the
client's aggregate socket status (idle → connecting → connected →
offline), 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:
- Reads resume from the cursor. The client sends the persisted
serverCursorassinceSeq. The server replays only the deltas you missed (or aresumeack 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. - Writes flush exactly once. Queued mutations are deduped by
mutationKeyand replayed on reconnect; the idempotency key means a retry after a flaky ack is a no-op server-side, not a duplicate. - Optimistic rows settle. A mutation's optimistic patch renders instantly, is superseded by the authoritative server row on acknowledgement, and rolls back if the server rejects it with a coded conflict.
- 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.
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.