Why we built Ano on Zero, not on the Slack model ← All posts<br>Why we built Ano on Zero, not on the Slack model
by Ruben Flam · Co-founder, Ano · posted May 12, 2026 · 6 min read · #engineering · #zero · #sync · #slack-alternative<br>Slack is a switchboard. So is Discord. So is Teams. Server in the<br>middle, WebSocket out, WebSocket in. Every chat tool you have used<br>in the last fifteen years works the same way, which is why they<br>all feel the same: fast on a perfect connection, fragile on a real<br>one, snappy in a small channel, sticky in a busy one. Switch a<br>workspace and you wait. Open the app on a plane and you stare at<br>a blank pane.
I have been writing software long enough to know what waiting on<br>the network feels like. We were not going to ship another one of<br>those.
Working inward
Picture every channel, every thread, every reaction, every file,<br>already there. You scroll, it scrolls. You switch, it is loaded.<br>You send, it is sent before you have thought about whether you<br>should have. The UI never feels like it is waiting for the<br>network because it is not.
That picture only works if the data is local. Not a cache. Not a<br>sync layer pasted on top of REST. Local like SQLite local, with<br>the server as the source of truth that the client and server stay<br>in lockstep about. We surveyed the field. Then we picked Zero.
Zero. Sync that feels local.
What Zero is
Zero is a sync engine from Rocicorp, the crew behind Replicache.<br>Postgres on the server, a local row store on the client<br>(IndexedDB on the web via Replicache, real SQLite on iOS),<br>Incremental View Maintenance in the middle keeping queries live<br>as the database changes. Queries are written in ZQL , a typed<br>query language that runs on both sides. Mutators run on the<br>client first (instant) and on the server second (authoritative).<br>Same code, two sides.
That last part is the unlock. Not a client and a server bolted<br>together. One model with two execution paths.
What we ruled out
Replicache. Mature. You write the read API and the conflict<br>resolution by hand. A lot of code we did not want to own.
ElectricSQL. Close to right. Reactive query story was thin.<br>Multi-region was further out than we needed.
PowerSync. Solid on mobile sync, weaker on reactive reads.<br>We would have rebuilt UI invalidation by hand. Again.
Convex. Beautiful DX. Hosted lock-in. We need our Postgres,<br>our regions, our keys.
Liveblocks. Built for cursors and shared docs. Not for a<br>50,000-message channel with a year of history.
Firebase / Firestore. I have watched too many teams hit the<br>wall on this. No real query language, no real schema, no thanks.
Yjs / Automerge. CRDTs are the right shape for documents,<br>the wrong shape for an ordered, durable, queryable stream of<br>messages.
Zero gave us four things in one package: a typed query language<br>we can compose, mutators that run on both sides without forking<br>the code, Postgres as the source of truth, and a reactive client<br>that updates the UI when rows change. Nothing else hit all four.
ZQL
You write the same query on the server (route handler, background<br>job, script) and on the client (React hook, Swift view). Same<br>code, same shape back. Compose with .related(...), scope with<br>.where(...), bound with .limit(...). The IVM engine watches<br>the rows behind the result and the moment one of them changes,<br>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(<br>z.query.messages<br>.where("channel_id", channelId)<br>.where("deleted_at", "IS", null)<br>.related("sender")<br>.related("reactions", (q) => q.related("user"))<br>.related("thread_replies", (q) => q.limit(3))<br>.orderBy("created_at", "desc")<br>.limit(50),<br>);<br>That hook is the whole story for an Ano channel view. Sender<br>info, reactions, the user behind each reaction, the first three<br>replies of every thread, composed once, rendered live. When<br>someone five timezones away reacts to a message you can already<br>see, your UI updates without you writing a line of fanout code.<br>The same feature in Slack takes a fanout service, a Redis bus,<br>and half a dozen WebSocket payload types.
The schema is the same shape on both sides:
import {<br>table, string, number, boolean,<br>createSchema, relationships,<br>} from "@rocicorp/zero";
const messages = table("messages")<br>.columns({<br>id: string(),<br>channel_id: string(),<br>sender_id: string(),<br>body: string(),<br>created_at: number(),<br>deleted_at: number().optional(),<br>})<br>.primaryKey("id");
const messageRelationships = relationships(messages, ({ one, many }) => ({<br>sender: one({<br>sourceField: ["sender_id"],<br>destField: ["id"],<br>destSchema: users,<br>}),<br>reactions: many({<br>sourceField: ["id"],<br>destField: ["message_id"],<br>destSchema: reactions,<br>}),<br>}));
export const schema = createSchema({<br>tables: [messages, channels, users, reactions],<br>relationships: [messageRelationships /* ... */],<br>});<br>Custom mutators
Write the mutator once. It runs in two places.
// shared-mutators.ts<br>export function...