Local-First Architecture
Client-side SQLite, offline sync, and the rebase model.
import { defineQuery } from '../_generated/valet/api'
export const list = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => ctx.db.query('tasks').collect(),
})This query reads from a SQLite database on the client. No network round-trip. No loading spinner. Works offline.
What local-first means
Traditional client-server apps fetch data over the network on every read. Every interaction waits for a round-trip. If the network drops, the app breaks.
Local-first apps store data on the client. Reads hit a local database and return instantly. Writes apply locally first, then sync to the server in the background. The app keeps working whether the network is available or not.
Valet stores your data in SQLite running on the client (via WebAssembly on web, native SQLite on React Native). The UI renders from local data, and Valet handles synchronization transparently.
The client stack
Three components make up the client-side architecture:
LocalDatabase is a SQLite database running on the client. On web, it uses wa-sqlite compiled to WebAssembly. On React Native, it uses native SQLite. All operations are serialized through a mutex for safe concurrent access.
SyncEngine bridges the local database and the server. It applies incoming deltas to local SQLite, tracks which documents have been synced, and manages the mutation log for offline replay.
ValetClient manages the WebSocket connection, handles authentication, and coordinates queries and mutations between client and server.
How local queries work
When you set execution: 'local' on a query, the handler runs against the client's SQLite database:
import { defineQuery, v } from '../_generated/valet/api'
export const listCompleted = defineQuery({
args: { completed: v.number() },
execution: 'local',
handler: async (ctx, args) => {
return ctx.db
.query('tasks')
.filter((q) => q.eq('completed', args.completed))
.collect()
},
})The data in the local database comes from sync filters you define in your schema. Valet syncs documents from the server based on those filters, and local queries read from that synced data. No network call. Instant results. Works offline.
How mutations work
Mutations are server-authoritative. The server is the source of truth for all writes.
import { defineMutation, v } from '../_generated/valet/api'
export const create = defineMutation({
args: { title: v.string() },
handler: async (ctx, args) => {
return ctx.db.insert('tasks', {
title: args.title,
completed: 0,
userId: ctx.auth?.userId ?? 'anonymous',
})
},
})When online, the mutation is sent to the server. The server executes the handler, persists the result, and broadcasts deltas to all connected clients (including the one that sent the mutation).
When offline (with SyncEngine configured), the handler runs locally against the client's SQLite database as an optimistic update. The mutation is also recorded in the mutation log for later sync.
Offline flow
- The handler executes against local SQLite (optimistic update)
- The UI updates immediately with the local result
- The mutation is recorded in the mutation log with captured environment
- On reconnect, the client receives fresh server state via sync
- Pending mutations are rebased against the fresh state (see below)
- Rebased mutations are sent to the server for execution
- The server confirms or rejects each mutation
- On rejection, the local optimistic write rolls back
Conflict resolution via rebase
When a client reconnects after being offline, its pending mutations may be based on stale data. Valet resolves this with a rebase model, conceptually similar to git rebase.
On reconnect:
- The server sends fresh state via the active sync subscriptions
- The SyncEngine writes the fresh state to local SQLite
- Pending mutations from the mutation log are replayed against the fresh state
- Each handler re-executes against current data, producing updated results
- The rebased mutations are sent to the server
Why rebase instead of merge
Merge-based approaches reconcile values. Rebase reconciles intent.
Consider a counter increment. Two clients both read a counter value of 5 and write 6:
| Approach | Client A writes | Client B writes | Result |
|---|---|---|---|
| Merge (last-write-wins) | 6 | 6 | 6 (one increment lost) |
| Rebase | 6 | reads 6, writes 7 | 7 (both increments preserved) |
With merge, the system sees two independent writes of 6 and picks one. With rebase, Client B's handler re-executes against Client A's already-committed state, reads 6, and writes 7. The intent (increment) is preserved.
Deterministic replay
Replaying a handler must produce the same non-deterministic values (IDs, timestamps, random numbers) as the original execution. Valet captures these values on first execution and replays them identically:
| Global | Behavior |
|---|---|
crypto.randomUUID() | Captured on first execution, replayed identically |
Math.random() | Captured on first execution, replayed identically |
Date.now() | Frozen to the captured timestamp |
new Date() | Uses the patched Date.now(), so also deterministic |
You write standard JavaScript. No special APIs required:
import { defineMutation, v } from '../_generated/valet/api'
export const create = defineMutation({
args: { title: v.string() },
handler: async (ctx, args) => {
const id = crypto.randomUUID() // deterministic on replay
const now = Date.now() // deterministic on replay
return ctx.db.insert('tasks', {
title: args.title,
externalId: id,
createdAt: now,
})
},
})Online concurrent writes
Rebase handles the offline case. For concurrent writes from multiple online clients, Valet provides two mechanisms:
Field-level last-write-wins: When clients update different fields or don't specify a version, the last write the server processes is the value that persists. All clients converge to the same state with no conflict errors or merge dialogs.
Optimistic locking with _version: When clients need to prevent lost updates to the same document, they can use the _version field. Each document has a _version that increments on every update. If a client specifies a _version in a write operation and it doesn't match the current version, the server returns a VERSION_CONFLICT error. The client must handle this error and retry with fresh data.
Example of optimistic locking:
// Read current document
const doc = await ctx.db.get('todos', todoId)
// Update with version check
try {
await ctx.db.update('todos', todoId, {
_version: doc._version, // Fails if another client updated it
completed: 1
})
} catch (err) {
if (err.code === 'VERSION_CONFLICT') {
// Another client modified this document
// Re-read and retry
}
}Optimistic locking is complementary to rebase:
- Rebase resolves conflicts for offline mutations by replaying operations against fresh state
- Optimistic locking prevents conflicts for online writes by detecting stale versions and failing fast
When to use server execution mode
Not all queries and mutations should run locally. Use execution: 'server' when:
- The query needs data the client does not have. Cross-user aggregations, full-text search, or analytics queries that span more data than any single client syncs.
- The mutation must validate server-side. Payments, permission checks, or any operation where the server must enforce rules before persisting.
- The dataset is too large to sync to the client. If syncing all matching documents to the client is impractical, run the query on the server and subscribe to the results.
Server queries subscribe via WebSocket and receive delta updates. Server mutations wait for the server response before updating the UI.
See How Sync Works for details on the sync protocol.