Offline support
lsync can cache eager collection snapshots in IndexedDB. Cached rows hydrate TanStack DB before the WebSocket connects, so previously synchronized data is available immediately after a reload or while the device is offline.
Enable it on a collection definition with offline():
import { appCollections } from "./definition";
import { collectionTypesFrom } from "@lsync/client";
export const { todos } = collectionTypesFrom(appCollections)
.url("ws://localhost:8787/sync/demo")
.collection("todos", (todos) => todos.sync("eager").offline())
.build();The generated collection ID is used as the stable IndexedDB cache key. For direct collectionOptions usage, provide an explicit id:
const options = collectionOptions({
id: "todos",
collection: "todos",
url: "ws://localhost:8787/sync/demo",
getKey: (todo: Todo) => todo.id,
offline: true,
});By default rows are stored in the lsync IndexedDB database. You can isolate an application and invalidate schema-incompatible cached data with:
.offline({ databaseName: "my-app", cacheVersion: 2 })After local hydration, the server remains authoritative. A successful connection replaces the cached snapshot atomically; subsequent server broadcasts and accepted local mutations update the cache. A failed server push still rolls back and is not written to the cache.
Current Scope
IndexedDB caching currently supports eager, readable collections. On-demand collections are rejected because their subset-completeness metadata is not yet durable. Persisting rows without that metadata could incorrectly claim that a partial subset is complete.
This feature is a durable read cache, not a durable mutation queue. TanStack DB still applies local mutations optimistically, and a failed push rolls back as usual. If users need to write while offline, persist domain intents separately and replay them idempotently:
- Assign each browser profile a stable
clientId. - Persist pending user intents when offline or when a mutation fails.
- Replay idempotent intents when the browser returns online.
Stable client identity for intent replay
import { appCollections } from "./definition";
import { collectionTypesFrom } from "@lsync/client";
const clientId =
localStorage.getItem("lsync-client-id") ??
(() => {
const id = crypto.randomUUID();
localStorage.setItem("lsync-client-id", id);
return id;
})();
export const { todos } = collectionTypesFrom(appCollections)
.url("ws://localhost:8787/sync/demo", { clientId })
.collection("todos", (todos) => todos.sync("on-demand"))
.build();Persistent intent queue
type PendingTodoIntent =
| { id: string; type: "insert"; todo: Todo }
| { id: string; type: "complete"; todoId: string; completed: boolean };
async function applyIntent(intent: PendingTodoIntent) {
if (intent.type === "insert") todos.insert(intent.todo);
if (intent.type === "complete") {
todos.update(intent.todoId, (draft) => {
draft.completed = intent.completed;
});
}
}
export async function addTodo(text: string) {
const intent: PendingTodoIntent = {
id: crypto.randomUUID(),
type: "insert",
todo: { id: crypto.randomUUID(), text, completed: false },
};
await intentStore.put(intent); // IndexedDB-backed application storage
try {
await applyIntent(intent);
await intentStore.delete(intent.id);
} catch {
// Keep the intent for replay.
}
}
window.addEventListener("online", async () => {
for (const intent of await intentStore.all()) {
await applyIntent(intent);
await intentStore.delete(intent.id);
}
});Use operation IDs or domain-level document APIs to make replay idempotent. Optimistic in-memory mutation state alone is not a durable offline queue.