Valet

Offline

How offline works with the SyncEngine, mutation log, and deterministic replay.

// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'

export const create = defineMutation({
    args: { title: v.string() },
    handler: async (ctx, args) => {
        // This works offline. The mutation runs locally, the UI updates
        // immediately, and the mutation syncs to the server on reconnect.
        return ctx.db.insert('todos', {
            title: args.title,
            completed: false,
            createdAt: Date.now(),
        })
    },
})

Valet works offline by default. Queries with execution: 'local' read from a client-side SQLite replica. Mutations run locally for an optimistic update and queue in the mutation log. When connectivity returns, queued mutations rebase against fresh server state and replay.

How offline works

The SyncEngine manages three pieces of local state:

  1. A SQLite database (via WebAssembly) that holds the synced documents
  2. A mutation log (persisted in IndexedDB) that holds pending mutations
  3. A connection manager that handles reconnection

When the app is online, the SyncEngine syncs documents from the server into local SQLite and sends mutations to the server in real time. When the connection drops, queries continue reading from local SQLite and mutations continue writing to it -- the user experience does not change.

Local execution for instant reads

Queries with execution: 'local' run against the client-side SQLite replica:

// valet/todos.ts
import { defineQuery } from './_generated/valet/api'

export const list = defineQuery({
    args: {},
    execution: 'local',
    handler: async (ctx) => {
        return ctx.db.query('todos').collect()
    },
})

The handler executes in the browser against local data. No network request. No loading spinner. The query re-runs automatically when synced data changes or when a local mutation modifies the table.

Mutation queuing

When a mutation runs offline, Valet:

  1. Runs the handler locally against client-side SQLite
  2. Applies the writes to the local database (optimistic update)
  3. Serializes the mutation (function name, args, captured non-determinism) into the mutation log
  4. Persists the mutation log to IndexedDB so it survives page refreshes

The mutation log is an ordered queue. Mutations replay in the same order they were created.

The rebase model

On reconnect, pending mutations do not send their local results to the server. Instead, they rebase:

  1. The server sends fresh state via sync
  2. The SyncEngine replaces local data with the fresh server state (via full sync: delete all + re-insert)
  3. Each pending mutation's handler replays against the current server state
  4. The replayed mutation writes to the local DB and is sent to the server for confirmation
  5. If the server confirms, the write persists. If the server rejects, the optimistic update rolls back.

This is conceptually identical to git rebase. Your offline changes replay on top of the latest state rather than merging in parallel. Read-then-write patterns (like incrementing a counter) produce correct results because the handler re-reads the current value before writing.

The "replacement" of local data happens automatically through the full sync — the server's subscribe_response atomically clears and repopulates each table within a transaction. No explicit rollback is needed because the fresh state already overwrites stale data.

Deterministic replay

Non-deterministic values would produce different results on replay. Valet captures these values on first execution and replays them identically:

GlobalBehavior
crypto.randomUUID()Captured on first run, replayed with same value
Math.random()Captured on first run, replayed with same value
Date.now()Frozen to the timestamp captured on first run
new Date()Uses the patched Date.now(), so also deterministic

You write normal JavaScript. No special APIs or wrappers:

// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'

export const create = defineMutation({
    args: { title: v.string() },
    handler: async (ctx, args) => {
        const externalId = crypto.randomUUID()
        const now = Date.now()

        return ctx.db.insert('todos', {
            title: args.title,
            completed: false,
            externalId,
            createdAt: now,
        })
    },
})

On replay, crypto.randomUUID() returns the same UUID and Date.now() returns the same timestamp. The document created during replay is identical to the one created during the original optimistic execution.

Connection state hooks

Monitor connection state in your React components:

useIsConnected

Returns true when the WebSocket is connected and syncing:

// StatusBar.tsx
import { useIsConnected } from './_generated/valet/react'

function StatusBar() {
    const isConnected = useIsConnected()

    return (
        <div>
            {isConnected ? 'Connected' : 'Offline'}
        </div>
    )
}

useIsReconnecting

Returns true when the client is attempting to reconnect after a connection drop:

// ReconnectBanner.tsx
import { useIsReconnecting } from './_generated/valet/react'

function ReconnectBanner() {
    const isReconnecting = useIsReconnecting()

    if (!isReconnecting) return null
    return <div>Reconnecting...</div>
}

useOfflineMode

Returns a [boolean, (offline: boolean) => void] tuple. The first element is true when the SyncEngine is in offline mode. The second element is a function to programmatically enable or disable offline mode:

// OfflineBanner.tsx
import { useOfflineMode } from './_generated/valet/react'

function OfflineBanner() {
    const [isOffline, setOfflineMode] = useOfflineMode()

    if (!isOffline) return null
    return (
        <div>
            You are offline. Changes will sync when you reconnect.
            <button onClick={() => setOfflineMode(false)}>
                Reconnect
            </button>
        </div>
    )
}

usePendingMutationCount

Returns the number of mutations in the mutation log waiting to sync:

// PendingIndicator.tsx
import { usePendingMutationCount } from './_generated/valet/react'

function PendingIndicator() {
    const pendingCount = usePendingMutationCount()

    if (pendingCount === 0) return null
    return <div>{pendingCount} pending changes</div>
}

WASM fallback

The SyncEngine requires WebAssembly for client-side SQLite. If WASM is not available (some restricted environments), Valet falls back to server-only mode:

  • Local queries become server queries (sent over WebSocket)
  • Mutations are sent directly to the server (no local optimistic updates)
  • Offline support is disabled

This fallback is automatic. Your code does not change. The app still works, but without the performance and offline benefits of local execution.

On this page