Slack is a switchboard. So is Discord. So is Teams. Server in the middle, WebSocket out, WebSocket in. Every chat tool you have used in the last fifteen years works the same way, which is why they all feel the same: fast on a perfect connection, fragile on a real one, snappy in a small channel, sticky in a busy one. Switch a workspace and you wait. Open the app on a plane and you stare at a blank pane.
We were not going to ship another one of those.
Working inward
Picture every channel, every thread, every reaction, every file, already there. You scroll, it scrolls. You switch, it is loaded. You send, it is sent before you have thought about whether you should have. The UI never feels like it is waiting for the network because it is not.
That picture only works if the data is local. Not a cache. Not a sync layer pasted on top of REST. Local like SQLite local, with the server as the source of truth that the client and server stay in lockstep about. We surveyed the field. Then we picked Zero.
What Zero is
Zero is a sync engine from Rocicorp, the crew behind Replicache. Postgres on the server, a local row store on the client (IndexedDB on the web via Replicache, real SQLite on iOS), Incremental View Maintenance in the middle keeping queries live as the database changes. Queries are written in ZQL, a typed query language that runs on both sides. Mutators run on the client first (instant) and on the server second (authoritative). Same code, two sides.
That last part is the unlock. Not a client and a server bolted together. One model with two execution paths.
What we ruled out
- Replicache. Mature. You write the read API and the conflict resolution by hand. A lot of code we did not want to own.
- ElectricSQL. Close to right. Reactive query story was thin. Multi-region was further out than we needed.
- PowerSync. Solid on mobile sync, weaker on reactive reads. We would have rebuilt UI invalidation by hand. Again.
- Convex. Beautiful DX. Hosted lock-in. We need our Postgres, our regions, our keys.
- Liveblocks. Built for cursors and shared docs. Not for a 50,000-message channel with a year of history.
- Firebase / Firestore. I have watched too many teams hit the wall on this. No real query language, no real schema, no thanks.
- Yjs / Automerge. CRDTs are the right shape for documents, the wrong shape for an ordered, durable, queryable stream of messages.
Zero gave us four things in one package: a typed query language we can compose, mutators that run on both sides without forking the code, Postgres as the source of truth, and a reactive client that updates the UI when rows change. Nothing else hit all four.
ZQL
You write the same query on the server (route handler, background
job, script) and on the client (React hook, Swift view). Same
code, same shape back. Compose with .related(...), scope with
.where(...), bound with .limit(...). The IVM engine watches
the rows behind the result and the moment one of them changes,
your subscription fires with the new shape.
No invalidation logic. No cache keys. No event bus.
import { useQuery } from "@rocicorp/zero/react";
const [messages] = useQuery(
z.query.messages
.where("channel_id", channelId)
.where("deleted_at", "IS", null)
.related("sender")
.related("reactions", (q) => q.related("user"))
.related("thread_replies", (q) => q.limit(3))
.orderBy("created_at", "desc")
.limit(50),
);
That hook is the whole story for an Ano channel view. Sender info, reactions, the user behind each reaction, the first three replies of every thread, composed once, rendered live. When someone five timezones away reacts to a message you can already see, your UI updates without you writing a line of fanout code. The same feature in Slack takes a fanout service, a Redis bus, and half a dozen WebSocket payload types.
The schema is the same shape on both sides:
import {
table, string, number, boolean,
createSchema, relationships,
} from "@rocicorp/zero";
const messages = table("messages")
.columns({
id: string(),
channel_id: string(),
sender_id: string(),
body: string(),
created_at: number(),
deleted_at: number().optional(),
})
.primaryKey("id");
const messageRelationships = relationships(messages, ({ one, many }) => ({
sender: one({
sourceField: ["sender_id"],
destField: ["id"],
destSchema: users,
}),
reactions: many({
sourceField: ["id"],
destField: ["message_id"],
destSchema: reactions,
}),
}));
export const schema = createSchema({
tables: [messages, channels, users, reactions],
relationships: [messageRelationships /* ... */],
});
Custom mutators
Write the mutator once. It runs in two places.
// shared-mutators.ts
export function createMutators() {
return {
message: {
send: async (
tx,
args: { id: string; channelId: string; body: string },
) => {
await tx.mutate.messages.insert({
id: args.id,
channel_id: args.channelId,
sender_id: tx.authData.userID,
body: args.body,
created_at: Date.now(),
});
},
},
};
}
On the client, that runs the moment the user hits return. The local row store gets the row. The UI updates. The user feels nothing because there is nothing to feel.
On the server, the same function runs again. Full auth context, the authoritative database, whatever extra checks you want to layer on:
// server-mutators.ts
import { createMutators as createSharedMutators } from "./shared-mutators";
export function createServerMutators() {
const shared = createSharedMutators();
return {
...shared,
message: {
...shared.message,
send: async (tx, args) => {
await assertChannelMember(tx, args.channelId);
await assertNotBanned(tx, tx.authData.userID);
await assertWithinRateLimit(tx, tx.authData.userID);
await shared.message.send(tx, args);
},
},
};
}
If the server’s truth disagrees with the client’s optimistic write (the user got banned half a second ago, say), Zero rolls back the client and replays from the authoritative answer. We never wrote the rollback code, because we never had to.
That is the architectural payoff. A stack of wire formats, RPC clients, queue workers, retry logic, and rollback handlers, collapsed into code you wrote once.
The iOS problem
Zero ships a TypeScript SDK. We needed iOS to feel as alive as the desktop app, on a thread that runs in Swift. Wrapping JS in a webview was off the table. That is the experience we are building Ano to replace.
So we wrote our own SDK. Native Swift, native SQLite, the Zero sync protocol over WebSocket, a reactive layer that hands SwiftUI observable query results in the same shape ZQL gives the web. Mutators round-trip through the same custom-mutator path. Optimistic state, rollback, schema versioning, all of it.
It took longer than I like to admit. It is also why an Ano channel on iPhone behaves the same as on macOS. Same speed, same offline behavior, same instant scroll. iOS feels like the rest of Ano because we wrote that SDK ourselves.
Where the servers run
Zero’s zero-cache is the process that talks to Postgres, runs
the IVM pipelines, and pushes deltas to clients. We run several:
- United States: us-east, us-west.
- European Union: eu-central, eu-north.
Each region has its own Postgres replica and its own pool of zero-cache workers. Clients pin to the region nearest the user. Zero gives us per-region zero-cache and the Postgres pipe; the cross-region write coordination is a layer we wrote on top. A known leader per record, replication out to the other regions, and a watermark on each user record so foreign-region replicas know when their cached copy is stale.
Two reasons. Latency: an EU user should not wait for a packet to round-trip to Virginia just to see their own keystrokes. Sovereignty: data residency in the EU is a real constraint for half the teams we talk to. “Your data lives in Frankfurt” is a clean answer.
What we got out of it
Two things we could not have built in any reasonable amount of time without Zero.
- Channels open instantly. Switching channels is a no-op for the network. The data is already on your machine. The view rerenders. No spinner because there is nothing to wait for.
- Search runs locally. Type a query, get results back from the local row store, in the same frame. Server-backed full-text is there for old archives. The hot path is local.
Then the smaller wins. Typing indicators are a query. Unread counts are a query. Mentions are a query. Presence is a query. Each used to be its own little system. Now each is a line of ZQL.
The point
We did not pick Zero because it was new. We picked it because the experience we wanted on top of team chat could not be built on the Slack model. Server-first puts the network in the loop on every interaction. A real local-first sync engine takes the network out of it. The user lives in the data. The data agrees with the server, eventually, fast.
We started with the user. We worked inward. The backend fell out of that.
If you want to use the thing we built, get on the beta. If you want to build the thing we built, we are hiring.