The latency problem
Traditional email clients make a network request for every interaction. Open a mailbox? API call. Select an email? API call. Search? API call. The user sees spinners. The network round-trip is the bottleneck.
JMAP improves this with batched requests — you can fetch mailboxes, emails, and threads in a single POST. But it's still fundamentally a request-response model. The UI still waits for the network.
Local-first architecture
The local-first approach inverts this: load data into a local store, and query it locally. The network is used for sync, not for reads. The UI never waits for the network to display data — it reads from the local store, which is always available.
Stich uses TanStack DB as this local store. It provides:
- ▸Collections — typed, normalized sets of data (mailboxes, emails, threads, identities)
- ▸Live queries — reactive queries over collections that update in <1ms when data changes
- ▸Optimistic mutations — insert/update/delete operations that apply instantly and rollback on error
- ▸Query-driven sync — collections can load only what queries request (on-demand mode)
How it works
1. Data flows in via JMAP
const emailCollection = createCollection(
queryCollectionOptions({
queryKey: ["emails", accountId],
queryFn: () => jmapClient.emailGet([], [...properties]),
queryClient,
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
await jmapClient.emailSet(create);
},
onUpdate: async ({ transaction }) => {
await jmapClient.emailSet(undefined, updates);
},
onDelete: async ({ transaction }) => {
await jmapClient.emailSet(undefined, undefined, ids);
},
})
);The collection loads data from the JMAP API via TanStack Query. Mutations are handled by the onInsert/onUpdate/onDelete handlers, which call the JMAP Email/set method.
2. Components use live queries
const { data: emails } = useLiveQuery((q) =>
q.from({ email: emailCollection })
.where(({ email }) => eq(email.mailboxIds[mailboxId], true))
.orderBy(({ email }) => email.receivedAt, "desc")
.select(({ email }) => email)
);The query is reactive. When the underlying collection changes — from a JMAP sync, a WebSocket push, or an optimistic mutation — the query result updates incrementally. No refetch needed.
3. WebSocket push updates the collection directly
jmapClient.onPush((change) => {
emailCollection.utils.refetch();
mailboxCollection.utils.refetch();
});When Stalwart pushes a state change over WebSocket, we refetch the collections. The live queries automatically pick up the changes and the UI re-renders with the new data.
Why not just React Query?
React Query is great for request-response patterns. But for an email client, you're managing interconnected data — emails belong to threads, threads belong to mailboxes, and queries need to join across these. React Query doesn't handle cross-cache joins or incremental view updates.
TanStack DB layers on top of React Query for data fetching and adds:
1. Normalized collections — each entity stored once, referenced by key 2. Live queries — differential dataflow engine computes incremental updates 3. Optimistic state — mutations applied locally, synced to server in background 4. Cross-collection joins — query across mailboxes, emails, and threads in one query
The result: an email client where opening a mailbox, selecting an email, and marking it read all happen instantly. The network is for sync, not for reads.
Performance
Updating one row in a sorted 100,000-item collection completes in ~0.7ms on an M1 Pro. That's fast enough that optimistic updates feel truly instantaneous, even with complex queries and large datasets.
For an email client, this means you can load the entire mailbox locally and filter/sort/search without any network round-trip. The UI is always responsive. The data is always available.