Offline Patterns
Local-first by default
Section titled “Local-first by default”xNet apps don’t fetch data from a server — they read from a local database (IndexedDB in the browser). This means:
- Queries are instant — no loading spinners for cached data
- Mutations are immediate — writes go to local storage first
- The app works offline — no network required for core functionality
- Sync is eventual — changes propagate to peers when a connection is available
You don’t need to add special offline handling. The architecture is offline-first from the ground up.
How offline sync works
Section titled “How offline sync works”Normal operation (connected)
Section titled “Normal operation (connected)”User edits → Local Y.Doc update → Broadcast via WebSocket → Peers apply → Persist to IndexedDBOffline operation
Section titled “Offline operation”User edits → Local Y.Doc update → OfflineQueue (persisted) → Persist to IndexedDBReconnection
Section titled “Reconnection”WebSocket connects → OfflineQueue drains → Broadcast queued updates → Exchange state vectors → Merge remote changesThe OfflineQueue holds up to 1000 entries, persisted to IndexedDB immediately for crash resilience. On reconnect, entries are drained in order. If a broadcast fails, the entry stays at the front of the queue for retry.
Patterns
Section titled “Patterns”Optimistic UI
Section titled “Optimistic UI”Since mutations write to local storage first, the UI updates immediately. No optimistic update logic is needed — the data is already local:
function TaskList() { const { data: tasks } = useQuery(TaskSchema) const mutate = useMutate()
const addTask = () => { // This writes locally and returns immediately mutate.create(TaskSchema, { title: 'New task', status: 'todo' }) // useQuery re-renders with the new task — no loading state }
return ( <div> <button onClick={addTask}>Add Task</button> {tasks.map((t) => ( <TaskRow key={t.id} task={t} /> ))} </div> )}Connection status indicator
Section titled “Connection status indicator”import { useSyncManager } from '@xnet/react'
function NetworkBadge() { const sync = useSyncManager()
return ( <span> {sync.status === 'connected' ? 'Online' : 'Offline'} {sync.queueSize > 0 && ` (${sync.queueSize} pending)`} </span> )}Background sync for important nodes
Section titled “Background sync for important nodes”Track nodes so they sync even when no component has them open:
const sync = useSyncManager()
// Pin a node — it syncs in the background and never expires from the registrysync.track(nodeId, schemaId)Tracked nodes stay in the sync registry for 7 days by default. Pinned nodes never expire.
Handling merge results
Section titled “Handling merge results”When offline changes merge with remote changes, both Yjs (rich text) and the NodeStore (properties) resolve conflicts automatically:
- Rich text — Yjs CRDT merges character-by-character. Both users’ edits appear.
- Properties — Field-level LWW. The change with the higher Lamport timestamp wins. If two users edit different fields on the same node, both changes are preserved.
There are no merge conflict dialogs. The system is designed so that all peers converge to the same state deterministically.
Offline-aware forms
Section titled “Offline-aware forms”For forms that create multiple related nodes, use transactions to ensure atomicity:
const mutate = useMutate()
const createProjectWithTasks = async () => { const result = mutate.transaction((tx) => { const projectId = tx.create(ProjectSchema, { name: 'Acme' }) tx.create(TaskSchema, { title: 'Setup', project: projectId }) tx.create(TaskSchema, { title: 'Launch', project: projectId }) })
// All three nodes are created atomically in local storage. // They sync together when connected, ensuring no dangling references.}Storage
Section titled “Storage”xNet uses IndexedDB through the @xnet/storage adapter. Data is organized per-node:
- Y.Doc state — Full Yjs document state, BLAKE3-hashed for integrity
- Node properties — Structured data from the NodeStore
- Offline queue — Pending updates waiting to sync
- Registry — Tracked node set with last-synced timestamps
- Blobs — File attachments stored locally
The storage layer handles serialization, compression, and integrity checks. You don’t interact with IndexedDB directly.
Limits
Section titled “Limits”| Resource | Limit |
|---|---|
| Offline queue | 1000 entries (FIFO, oldest dropped) |
| Y.Doc size | 50 MB per document |
| Warm document pool | 50 documents in memory |
| Registry TTL | 7 days (configurable) |
If the offline queue fills up during an extended offline period, the oldest entries are dropped. The Y.Doc state in IndexedDB is still preserved — only the incremental updates that haven’t been broadcast are lost. On reconnect, a full state-vector exchange with peers will reconcile any gaps.