# Notes on Zero by Rocicorp
2025-05-09
### Figuring Out How Zero Connects: My Notes on Its Architecture
I’ve been diving into [Zero](https://zero.rocicorp.dev/), the new query-powered sync engine, and I'm genuinely impressed. It’s still in alpha, so things are evolving, but the core ideas are incredibly compelling for building fast, reactive web apps.
One thing I found myself sketching out was a clear mental model of how all its main pieces talk to each other, especially with the new [Custom Mutators API](https://zero.rocicorp.dev/docs/custom-mutators) (which, by the way, is a fantastic move away from the older client-declared CRUD updates with a separate permissions system – having server-authoritative mutations makes so much more sense).
So, here are my notes on the moving parts, for anyone else trying to get a quick grasp:
At the heart of it, you have your **PostgreSQL Database**. This is your single source of truth. `zero-cache` listens to this database using Postgres Event Triggers and logical replication, so it knows about every change.
Then there's the **`zero-cache` Service** itself. This is a standalone process you run. It takes the data replicated from Postgres, keeps an optimized SQLite replica, and is the thing your clients actually talk to. Client applications connect to `zero-cache` (usually `localhost:4848` in dev) via a persistent WebSocket. This WebSocket is key: `zero-cache` streams reconciled, real-time updates down it. This means if data changes – whether from the current user, another browser tab, or even a direct database update elsewhere – all connected clients see it live in their queries. It also means client-side mutations can be "fire-and-forget"; you don't await them, you just trust the reconciled state will stream back. However all mutations are reflected immediately – letting the reconciliation be a "second pass" update that the client rarely sees or has to worry about. When your client *does* want to change data using a custom mutator, `zero-cache` passes that request on by calling a `/push` endpoint on your application backend.
This brings us to **Your Application Backend**. This is your own server-side code (Node.js, etc.) where your business logic lives. You implement a `/push` HTTP endpoint here. When `zero-cache` calls this endpoint with a mutation request, your backend code executes the authoritative logic – validation, any side effects, and crucially, writing the actual changes to your PostgreSQL database.
Defining *what* data `zero-cache` and your clients care about is done via a **TypeScript Schema (`schema.ts`)**. This file declares the tables, columns, and relationships. It’s used by `zero-cache` to know what to replicate and by your client for type-safe queries. Your `/push` endpoint might also use it if you're leveraging Zero's server-side ZQL helpers.
And defining *how* data changes is the job of **TypeScript Custom Mutators (`mutators.ts`)**. These are functions you write. There's a client-side part that runs immediately for that snappy optimistic UI update. Then, the server-side part of that mutator is what your Application Backend executes via the `/push` endpoint to make the real change in Postgres. Client mutations must use ZQL for CRUD operations, a query language that works in both client and server settings. Server mutations can just re-run the ZQL by literally calling the client mutation (but this time, internally, with a real database connection) — but since the server setting is within `/push` you now have your choice of ORM. Additionally, this is usually where you'd do out-of-bounds work like send push notifications, queue tasks or whatever. What does this mean? For free, you get optimistic updates (snappy UI!), row-level server authoritative reconciliation, live sync between browser tabs and users and you never import `` components.
Finally, **Your Client Application** (React, SolidJS, etc.) uses the Zero client library. It connects to `zero-cache` over WebSockets, uses the schema and mutators, and subscribes to live queries that just... update. Zero provides great React hooks for live queries and mutations.
The flow feels really well thought out: client optimistic update -> `zero-cache` -> your backend's `/push` endpoint -> backend writes to Postgres -> Postgres replication tells `zero-cache` -> `zero-cache` streams update to all clients.
I'm evaluating Zero quite seriously at [Trip To Japan](https://www.triptojapan.com/) for parts of our internal admin tooling. The promise of "it just works" real-time data without complex state management is very appealing.
It's early days for Zero, but the architecture, especially with custom mutators handling writes authoritatively on the server, feels like a solid foundation. Definitely one to watch.
If you're curious, here's my `/push` handler that I plomped into Next.js – it can be much simpler, but I added a custom interface for `node-postgres` (`postgres.js` interface is included), as well as JWT decoding:
```ts
import type { JSONValue } from "@rocicorp/zero";
import { PushProcessor, ZQLDatabase } from "@rocicorp/zero/pg";
import { NextResponse, type NextRequest } from "next/server";
import { schema } from "@trip/zero";
import { decodeAuthJWT } from "@trip/zero/auth";
import { DrizzleConnection } from "@trip/zero/drizzle";
import { createServerMutators } from "@trip/zero/server-mutators";
import { env } from "~/env";
import { withDb } from "~/lib/db";
async function handler(request: NextRequest) {
const json = await request
.json()
.then((data) => data as JSONValue)
.catch(() => {
return {};
});
const subResult = decodeAuthJWT(
request.headers.get("Authorization"),
env.SECRET,
);
if (subResult.isErr()) {
console.error("Error decoding JWT", subResult.error.message);
return new NextResponse(subResult.error.message, { status: 401 });
}
const authData = subResult.value;
const asyncTasks: (() => Promise)[] = [];
const mutators = createServerMutators(authData, asyncTasks);
await Promise.all(asyncTasks.map((task) => task()));
const response = await withDb(async (db) => {
const processor = new PushProcessor(
new ZQLDatabase(new DrizzleConnection(db), schema),
);
const searchParams = request.nextUrl.searchParams;
const response = await processor.process(mutators, searchParams, json);
return NextResponse.json(response);
});
return response;
}
export { handler as GET, handler as POST };
```
As I'm using Drizzle with a node-postgres `PoolClient` I created a custom database interface. This allows me to use Drizzle in server mutations.
```ts
import type { DBConnection, DBTransaction, Row } from "@rocicorp/zero/pg";
import { type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { PoolClient, QueryResultRow } from "pg";
import type * as schema from "@trip/db";
type Drizzle = NodePgDatabase & { $client: PoolClient };
export type DrizzleTransaction = Parameters<
Parameters[0]
>[0];
export class DrizzleConnection implements DBConnection {
drizzle: Drizzle;
constructor(drizzle: Drizzle) {
this.drizzle = drizzle;
}
query(sql: string, params: unknown[]): Promise {
return this.drizzle.$client
.query(sql, params)
.then(({ rows }) => rows);
}
transaction(
fn: (tx: DBTransaction) => Promise,
): Promise {
return this.drizzle.transaction((drizzleTx) =>
fn(new Transaction(drizzleTx)),
);
}
}
class Transaction implements DBTransaction {
readonly wrappedTransaction: DrizzleTransaction;
constructor(drizzleTx: DrizzleTransaction) {
this.wrappedTransaction = drizzleTx;
}
query(sql: string, params: unknown[]): Promise {
const session = this.wrappedTransaction._.session as unknown as {
client: Drizzle["$client"];
};
return session.client
.query(sql, params)
.then(({ rows }) => rows);
}
}
```
And here's a layout.tsx component that passes down a JWT that is used for auth. Your mileage may vary:
```tsx
import { encodeAuthJWT } from "@trip/zero/auth";
import { env } from "~/env";
import { withDb } from "~/lib/db";
import { requireAdmin } from "~/server/session";
import { Provider } from "./_components/provider";
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const adminUser = await withDb(requireAdmin);
return (
{children}
);
}
```
The provider just wraps ``:
```tsx
"use client";
import { Zero } from "@rocicorp/zero";
import { ZeroProvider } from "@rocicorp/zero/react";
import type { ReactNode } from "react";
import { createMutators, schema } from "@trip/zero";
import type { SessionUser } from "~/server/session";
export function Provider({
children,
auth,
user,
}: {
auth: string;
user: SessionUser;
children: ReactNode;
}) {
const zero = new Zero({
userID: user.id,
auth,
server: "http://localhost:4848",
schema,
mutators: createMutators({ sub: user.id, role: "admin" }),
});
return {children};
}
```
Here's page.tsx
```tsx
"use client";
import { useQuery, useZero } from "@rocicorp/zero/react";
import { type createMutators, type Schema } from "@trip/zero";
type Mutators = ReturnType;
const useZ = useZero;
export default function Page() {
const z = useZ();
const [orders] = useQuery(
z.query.Order.related("tripLineItems")
.where("chargedAt", "IS NOT", null)
.orderBy("chargedAt", "desc")
.limit(20),
);
return (
{orders.map((order) => (
))}
);
}
```
mutators.ts
```ts
import type { CustomMutatorDefs } from "@rocicorp/zero";
import type { AuthData, Schema } from "./index";
export function createMutators(_authData: AuthData) {
return {
reverseOrderNameCasing: {
update: async (
tx,
{ id, fullName }: { id: string; fullName: string },
) => {
if (fullName.length > 100) {
throw new Error(`Title is too long`);
}
// Reverse the case of fullName
const reversedCaseFullName = fullName
.split("")
.map((char) =>
char === char.toUpperCase()
? char.toLowerCase()
: char.toUpperCase(),
)
.join("");
await tx.mutate.Order.update({ id, fullName: reversedCaseFullName });
},
},
} as const satisfies CustomMutatorDefs;
}
export type ClientMutators = ReturnType;
```
And server-mutators.ts
```ts
import type { ServerTransaction } from "@rocicorp/zero";
import type { CustomMutatorDefs } from "@rocicorp/zero/pg";
import { eq } from "drizzle-orm";
import { Order } from "@trip/db";
import type { AuthData } from "./auth";
import type { DrizzleTransaction } from "./drizzle";
import { createMutators } from "./mutators";
import type { Schema } from "./zero-schema.gen";
export function createServerMutators(
authData: AuthData,
_asyncTasks: (() => Promise)[] = [],
) {
const clientMutators = createMutators(authData);
return {
...clientMutators,
reverseOrderNameCasing: {
update: async (
tx,
{ id, fullName }: { id: string; fullName: string },
) => {
await clientMutators.reverseOrderNameCasing.update(tx, {
id,
fullName,
});
/// Here just as an example of doing something with the Drizzle query builder
await tx.dbTransaction.wrappedTransaction
.update(Order)
.set({ fullName: `🔧 ${fullName}` })
.where(eq(Order.id, id));
},
},
} as const satisfies CustomMutatorDefs<
ServerTransaction
>;
}
```