Zero to Sixty: Building Instant Web Apps with Rocicorp Zero
If you have ever built a rich, interactive web app, you know the ritual: the UI fires a request, an API endpoint answers, the database does its thing, and somewhere in the middle a spinner spins. Then you hand-roll optimistic updates, cache invalidation, and a websocket layer to keep everything fresh. Zero, the sync engine from Rocicorp (the team behind Replicache), collapses that entire stack into something much simpler: you write normal queries, and Zero syncs exactly the data those queries need into a local datastore.
The defining idea is that queries are the unit of sync. Instead of shipping whole tables to the browser or maintaining static sync rules, you control what syncs by writing ordinary queries in your app code. Reads and writes hit a local, normalized client store first, so the UI feels instant, and Zero continuously reconciles with your server in the background. When you query data that is not local yet, Zero automatically falls back to the server and backfills it. The repo tagline says it best: "99% of Queries in Zero Milliseconds."
Why a Sync Engine Changes the Game
Zero is deliberately a sync engine, not a syncing datastore. Your real data still lives in Postgres, which remains the system of record. Zero sits in front of it and streams the relevant subset down to clients. That distinction matters: tools like Convex or InstantDB are the database, while Zero keeps your existing Postgres and syncs from it. You keep control and ownership of your data.
A few capabilities make this worth the trade:
- Query-driven partial sync with automatic server fallback, so you never ship the whole database to the browser.
- ZQL, a typesafe, relational, chainable query API that runs on both the client and the server.
- Live reactive queries powered by an Incremental View Maintenance (IVM) engine that updates results incrementally instead of re-running them, which is where the sub-millisecond claim comes from.
- Custom mutators that run optimistically on the client for instant feedback and authoritatively on the server for the source of truth.
- A persistent, normalized local datastore that survives reloads and works offline for already-synced data.
- End-to-end TypeScript types, from your schema definition through queries all the way to your components.
Bringing Zero Aboard
Zero ships as a single package that bundles both the client library and the zero-cache server.
npm install @rocicorp/zero
Or with yarn:
yarn add @rocicorp/zero
One important note before you get too comfortable: Zero is infrastructure, not a drop-in hook. Alongside the npm install you will run zero-cache, a stateful sync server, and you need a Postgres instance (version 15 or newer) with logical replication enabled (wal_level = logical). The zero-cache process keeps an open logical-replication connection to Postgres, maintains a SQLite replica of the relevant data, and runs the same IVM engine server-side to push query deltas to connected clients over websockets. It is designed to scale horizontally, but it is real backend you operate.
Wiring Up the Provider
Everything in a React app flows through ZeroProvider. It establishes the connection to your zero-cache, loads your schema, registers your mutators, and carries the authentication and context that your queries and writes will rely on.
import {ZeroProvider} from '@rocicorp/zero/react'
import {schema} from './schema'
import {mutators} from './mutators'
export default function Root() {
const session = useSession()
const userID = session?.userID
const auth = session?.accessToken
const context = userID ? {userID} : undefined
return (
<ZeroProvider
{...{userID, auth, context, cacheURL, schema, mutators}}
>
<App />
</ZeroProvider>
)
}
The context object is the backbone of Zero's permission model. Rather than a separate row-level-security layer, permissions in Zero are filter-based and live in your app code: you authenticate the user, build a context like {userID}, and write queries that filter to the rows that user is allowed to see. A nice touch is that when auth changes from one token to another, the provider refreshes auth in place rather than tearing everything down.
Reading Data with ZQL
Reads go through the useQuery hook, which subscribes to a live query. When the underlying data changes, whether from your own writes or another user's, the component re-renders automatically.
import {useQuery} from '@rocicorp/zero/react'
import {queries} from './queries'
function Posts() {
const [posts] = useQuery(queries.posts.byStatus({status: 'draft'}))
return posts.map(p => <div key={p.id}>{p.title}</div>)
}
The queries themselves are written in ZQL, a chainable TypeScript API instead of raw SQL strings. It reads naturally and stays fully typed:
zql.post // whole table (filtered by perms)
zql.post.where('authorID', authorID) // equality filter
zql.issue.orderBy('created', 'desc') // ordering
zql.issue.orderBy('created', 'desc').limit(10)
Because ZQL queries are just values, you can compose them progressively in plain TypeScript, which makes conditional filtering a breeze:
let q = zql.post.where('authorID', authorID)
if (!includeDrafts) {
q = q.where('isDraft', false)
}
For anything more involved, an expression builder gives you or, exists, and comparison helpers, including across relationships:
zql.post.where(({cmp, exists, or}) =>
or(
cmp('authorID', ctx.id),
exists('sharedWith', q => q.where('userID', ctx.id)),
),
)
Baking Permissions Into Synced Queries
Because permissions are app-code discipline rather than enforced row-level security, the recommended pattern is to define synced queries that always carry their context filter. The golden rule is that if a user should not see certain rows, you return a query matching no rows rather than throwing an error.
const myPosts = defineQuery(({ctx}) => zql.post.where('authorID', ctx.id))
This keeps the filter attached to the query definition itself, so a component can never accidentally request a broader set than it should. It is a discipline you have to maintain, but it co-locates access rules with the data they protect, which is far easier to audit than scattered endpoint checks.
Writing Data with Custom Mutators
Writes in Zero are not REST calls; they are mutators. A mutator is a function that runs twice: optimistically on the client for an instant UI update, and authoritatively on the server as the final word. You define them once with validated arguments.
// src/mutators.ts
import {defineMutators, defineMutator} from '@rocicorp/zero'
import {z} from 'zod'
export const mutators = defineMutators({
updateIssue: defineMutator(
z.object({ id: z.string(), title: z.string() }),
async ({tx, args: {id, title}}) => {
if (title.length > 100) throw new Error('Title is too long')
await tx.mutate.issue.update({ id, title })
},
),
})
The transaction object exposes CRUD primitives on tx.mutate.<table>: insert, upsert, update (partial updates allowed), and delete. On the server side, the same context you used for reads enforces ownership, so you can stamp fields like authorID from the authenticated user instead of trusting the client:
async ({tx, ctx: {userID}, args: {id, title}}) => {
await tx.mutate.issue.insert({ id, title, authorID: userID })
}
Calling a mutator from a component is a single line through useZero:
import {useZero} from '@rocicorp/zero/react'
function CompleteButton({issueID}: {issueID: string}) {
const zero = useZero()
const onClick = () => zero.mutate(mutators.issues.complete({id: issueID}))
return <button onClick={onClick}>Complete Issue</button>
}
Awaiting Two Truths
Because a mutation resolves in two phases, Zero lets you await each one independently. The client result tells you the optimistic apply landed, typically in under a frame, while the server result is the authoritative confirmation.
const write = zero.mutate(mutators.issue.insert({/* ... */}))
const clientRes = await write.client // local apply, typically < 1 frame
if (clientRes.type === 'error') console.error(clientRes.error)
const serverRes = await write.server // authoritative confirmation
if (serverRes.type === 'error') console.error('Server rejected mutation')
This split is what makes optimistic UI honest. The client copy of the mutator exists mainly to make the interface feel instant; the server copy is free to diverge, adding extra validation, permission checks, and side effects. On your backend you wire mutators into a mutate endpoint that runs them transactionally:
const result = await handleMutateRequest({
dbProvider,
handler: transact =>
transact((tx, name, args) => {
const mutator = mustGetMutator(mutators, name)
return mutator.fn({args, tx})
}),
request,
userID: null,
})
If the server rejects a mutation, Zero rolls back the optimistic change on the client, so your local store never drifts from the source of truth for long.
A Few Things to Watch For
Zero is genuinely a delight for web developer experience, but go in with eyes open. It is Postgres-only as a system of record, so there is no MySQL or SQLite-as-source path. Provider support varies: AWS RDS and Aurora, Neon, Google Cloud SQL, PlanetScale for Postgres, and plain Docker are fully supported, while Supabase, Fly.io, and Render are partial because they lack event-trigger access, which means a schema change triggers a full reset of server-side and client-side state. That is fine for a small database and painful at scale.
The persistent replication connection to Postgres also has billing and connection implications on some managed providers, so check your plan. And since permissions are convention rather than enforced RLS, a query that forgets its context filter can leak data. Lean on the synced-query pattern to keep that risk contained.
Crossing the Finish Line
Zero reached 1.0 in early 2026 after nearly two years of development and 50-plus releases, and the team now considers the API stable; breaking changes from here on are meant to be rare and small. The current 1.5.0 release is the package to build on, and the canonical real-world example is zbugs, a full Linear-style bug tracker living in the Rocicorp monorepo and running at zbugs.rocicorp.dev.
If you are building a rich, interactive app, want to keep Postgres as your source of truth, and are tired of hand-writing CRUD endpoints, caching, optimistic updates, and websocket plumbing, Zero offers a genuinely different shape of app: queries as the sync boundary, a local query engine for instant reads, and mutators that reconcile optimistic writes with an authoritative server. You trade away a bit of operational simplicity (you do run zero-cache and a replication-enabled Postgres) for an experience where, most of the time, your queries really do resolve in zero milliseconds.