Testing Zero: Rocicorp's Ultra-Fast Sync Engine for the Web

Jean-Baptiste Kaiser
Jean-Baptiste KaiserFebruary 28, 2025
#architecture#database#performance#postgres#react#solidjs

On the 18th of December 2024 (almost for Christmas! 🎄), Rocicorp introduced a new bug tracker app, built using one of their own product: Zero, released six months earlier.

Zero is a new general-purpose sync engine for the web that promises instant (zero milliseconds—hence the name) response time for almost all user interactions.

While still in alpha, Zero looks very promising, so we decided to give it a try by building a small offline-first application to assess its performance and live synchronization features.

Back to Square Zero

First, let's go back to Zero for a moment. What is Zero exactly?

Rocicorp describes it as a synchronization engine. Basically, it's a layer that sits between your client application and the database. All queries and mutation requests are transited through it.

This layer is split into two parts:

  • A cache system that sits (server-side) between the clients and the database
  • A client library, which is embedded in your client app

Zero Architecture

The Zero cache is responsible for persisting the changes to the database, fetching the data, and also dispatching the database updates to the clients. It uses the Write-Ahead Logging (WAL) to accomplish this.

The Zero cache is also in charge of checking user authentication and permissions, as we'll see later.

The Zero client, on the other hand, is a JavaScript library you can embed in your web app. It is responsible for talking to the Zero cache and maintaining a local copy of a subset of the data (i.e., a local cache).

Both the Zero client and the Zero cache share the same Schema, a TypeScript file, conventionally called schema.ts, that describes:

  • The tables and columns definition
  • The relationships between tables
  • The authorization rules that control access to the database

Zero even supports many-to-many relationships.

Lastly, Zero introduces ZQL, Zero's Query Language. All queries and mutations need to be written using ZQL. If you have used Drizzle or Kysley, ZQL will feel familiar. This allows Zero to query the local cache and the real database using the same query language.

This whole architecture is what allows Zero to offer its main features. Let's review some of them.

(Much) More Than Zero Features

  • Instant queries and updates: Thanks to the local cache mentioned earlier, the Zero client is able to return most data and perform mutations blazingly fast. It performs them (optimistically) on its local copy of the data first while querying the real database in the background.
  • Offline mode: This local cache also allows the application to be used offline (provided that the data is already in the cache), which can benefit applications targeting mobile devices.
  • Two-way live database synchronization: The Zero client opens a WebSocket connection to the Zero cache. This allows for real-time updates whenever the database is updated, either by you or another user. It also supports cross-instance synchronization, for example, between tabs or browsers. Likewise, all changes performed directly in the database are synced to the clients immediately.
  • No need for a backend: One of Zero's goals is to keep the tech stack as simple as possible by eliminating the need for a backend. As we'll see later, the Zero cache system can handle authorization and access control for you.

Trying Out the Demo

Let's give Zero a quick try and get an overview of its features in action!

Zero features a demo app called hello-zero, which illustrates the architecture mentioned earlier and allows you to play with its features.

The setup is very straightforward. You only need to clone the GitHub project, install the dependencies, and then start the three app pieces.

# clone the project
git clone https://github.com/rocicorp/hello-zero.git
cd hello-zero
# install the dependencies
npm install
# start the database
npm run dev:db-up
# start the Zero cache
npm run dev:zero-cache
# start the UI
npm run dev:ui

Before you know it, you have a simple app running using Zero.

hello-zero uses React, but there also is a SolidJS version (hello-zero-solid).

In this app, you can notice how fast Zero syncs the data between the client and the database and also between clients.

It also demonstrates that the permission checks are in place. For instance, there is a rule stating that:

You can only edit messages from the user you’re logged in as.

However, the developers included a way to bypass this rule from the client app by holding the shift key. If you do, you can trigger a request to edit a message, but as soon as the mutation is sent to the database, it gets rejected, and the UI is refreshed to revert the modifications.

You can also try Zero in a more complete and production-ready app: Zero's official bug tracker.

Using Zero in Our App

Reading documentation and playing around with demos is a good way to grasp a technology's features and philosophy. But it's often not enough to evaluate the implementation's actual complexity, the Developer Experience (DX) it provides, and any difficulties or traps you might encounter.

So, we decided to build an application from scratch with Zero.

We initially hoped to benefit from Zero's features in our homemade CRM app, Atomic CRM, so we decided to base our example on its data model.

Atomic CRM Atomic CRM Atomic CRM

Atomic CRM is built on top of React Admin, which itself uses TanStack Query to manage queries and mutations.

Some TanStack Query features, like local cache and optimistic updates, would conflict with Zero, so for now, we decided to leave React Admin out of the way and reimplement a (very basic) CRM app from scratch.

The only piece we'll reuse from Atomic CRM is the database. It uses a Postgres database hosted on Supabase.

While Zero aims to support more databases in the future, at the time of this writing, it only supports Postgres.

It is still possible to connect Zero to a Postgres database hosted on Supabase, but it has some limitations and requires a small amount of additional configuration.

In general, Zero does not leverage and even conflicts with some Supabase features. That's why the docs recommend using a standard Postgres host instead (Postgres.app, docker image, AWS RDS, AWS Aurora, Google Cloud SQL, Fly.io Postgres...).

Bootstrapping the App

We named our app zero-crm. The project was initialized by copying parts of the hello-zero project and cleaning up everything that wasn't necessary or that we didn't understand yet.

// package.json
{
  "name": "zero-crm",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    // We kept the same scripts as hello-zero: dev:ui and dev:zero-cache
    "dev:ui": "vite",
    "dev:zero-cache": "zero-cache-dev -p src/schema.ts",
    // ...
  },
  "dependencies": {
    // The Zero client library
    "@rocicorp/zero": "^0.10.2024122404",
    // The Supabase JS client (needed for authentication)
    "@supabase/supabase-js": "^2.39.3",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    // ...
  }
}

Then, we tackled the schema file.

Creating the Schema File

For now, our app only uses Atomic CRM's sale and contact tables. So, let's add them to the schema.ts file.

import {
  createSchema,
  createTableSchema,
  definePermissions,
  Row,
} from "@rocicorp/zero";

const salesSchema = createTableSchema({
  tableName: "sales",
  columns: {
    id: "number",
    first_name: "string",
    last_name: "string",
    email: "string",
    administrator: "boolean",
    user_id: "string",
    avatar: "json",
    disabled: "boolean",
  },
  primaryKey: "id",
});

const contactsSchema = createTableSchema({
  tableName: "contacts",
  columns: {
    id: "number",
    first_name: "string",
    last_name: "string",
    gender: "string",
    title: "string",
    email: "string",
    avatar: "json",
    has_newsletter: "boolean",
    status: "string",
    company_id: "number",
    sales_id: "number",
    linkedin_url: "string",
  },
  primaryKey: "id",
  relationships: {
    sales: {
      sourceField: "sales_id",
      destSchema: salesSchema,
      destField: "id",
    },
  },
});

export const schema = createSchema({
  version: 1,
  tables: {
    sales: salesSchema,
    contacts: contactsSchema,
  },
});

export type Schema = typeof schema;
export type Sale = Row<typeof salesSchema>;
export type Contact = Row<typeof contactsSchema>;

// The contents of your decoded JWT
type AuthData = {
  sub: string | null;
};

// We'll get to that part later
export const permissions = definePermissions<AuthData, Schema>(
  schema,
  () => ({})
);

Community tools exist that allow you to generate a Zero schema from both Drizzle and Prisma.

You'll notice that contactsSchema defines a relationship to the sales table so that we can use it later in our queries.

However, before we can query the Zero engine, we need to set up the Zero client.

Configuring the Zero Client

To configure the Zero client, we'll need five things:

  • server: the URL to the zero-cache server endpoint
  • schema: the schema we created earlier
  • userID: a string representing the ID of the connected user (remember Zero manages authentication)
  • auth: a callback returning the connected user's JWT (again, to allow Zero to check authorizations)
  • kvStore: the key-value store to use. For instance "mem" for in-memory, or "idb" for local persistence.

In our case, we can get the userID and the JWT using the Supabase JS client.

Since our app uses React, we can also leverage the Zero React components, located in @rocicorp/zero/react. For instance, we'll use <ZeroProvider> to create a context to expose the Zero client to the whole app.

Here is the complete implementation.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { ZeroProvider } from "@rocicorp/zero/react";
import { Zero } from "@rocicorp/zero";
import { schema } from "./schema.ts";
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
);
const {
  data: { user },
} = await supabase.auth.getUser();
const userID = user?.id ?? "anon";

const z = new Zero({
  userID,
  auth: async () => {
    const {
      data: { session },
    } = await supabase.auth.getSession();
    const token = session?.access_token;
    return token;
  },
  server: import.meta.env.VITE_PUBLIC_SERVER,
  schema,
  // This is often easier to develop with if you're frequently changing
  // the schema. Switch to 'idb' for local-persistence.
  kvStore: "mem",
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ZeroProvider zero={z}>
      <App />
    </ZeroProvider>
  </StrictMode>
);

We are now ready to write our first query!

Writing Our First (Zeroth?) Query

Now that the Zero client is set up, let's write our first query using ZQL!

First, let's fetch and display the list of contacts.

import { useQuery, useZero } from "@rocicorp/zero/react";
import { Schema } from "./schema";

function App() {
  const z = useZero<Schema>();
  const [contacts] = useQuery(z.query.contacts);
  // If the initial sync hasn't been completed, these can be empty.
  if (!contacts.length) {
    return null;
  }

  return (
    <table border={1} cellSpacing={0} cellPadding={6} width="100%">
      <thead>
        <tr>
          <th>ID</th>
          <th>First Name</th>
          <th>Last Name</th>
        </tr>
      </thead>
      <tbody>
        {contacts.map((contact) => (          <tr key={contact.id}>            <td align="left">{contact.id}</td>            <td align="left">{contact.first_name}</td>            <td align="left">{contact.last_name}</td>          </tr>        ))}      </tbody>
    </table>
  );
}

export default App;

That's it! All contacts are fetched from the database. Once the server responds, contacts are stored in the local cache so that all subsequent queries will return instantly!

List of contacts fetched in zero-crm

Now let's add a mutation.

Writing Our Zeroth Mutation

We'll add a checkbox to toggle whether or not a contact is subscribed to the newsletter (the has_newsletter field in the database).

Here is the new version of the app:

import { useQuery, useZero } from "@rocicorp/zero/react";
import { Schema } from "./schema";

function App() {
  const z = useZero<Schema>();
  const [contacts] = useQuery(z.query.contacts);

  const toggleNewsletter = (id: number, prev: boolean) => {    z.mutate.contacts.update({      id,      has_newsletter: !prev,    });  };
  // If the initial sync hasn't been completed, these can be empty.
  if (!contacts.length) {
    return null;
  }

  return (
    <table border={1} cellSpacing={0} cellPadding={6} width="100%">
      <thead>
        <tr>
          <th>ID</th>
          <th>First Name</th>
          <th>Last Name</th>
          <th>Newsletter</th>
        </tr>
      </thead>
      <tbody>
        {contacts.map((contact) => (
          <tr key={contact.id}>
            <td align="left">{contact.id}</td>
            <td align="left">{contact.first_name}</td>
            <td align="left">{contact.last_name}</td>
            <td>
              <input                type="checkbox"                checked={contact.has_newsletter}                onChange={() => {                  toggleNewsletter(contact.id, contact.has_newsletter);                }}              />            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

export default App;

That's it. With a few added lines, we can update a contact row, and all changes are synced immediately to the local contact list, to the remote database, and across instances of the app.

Now, let's leverage the relationship we declared in schema.ts.

For each contact, let's display which salesperson oversees them.

In the database, each contact is linked to a salesperson via the sales_id field.

But, since we described that relationship in schema.ts, we don't need to join the data ourselves.

Here is the app's main file, which has been updated to fetch the related sales:

import { useQuery, useZero } from "@rocicorp/zero/react";
import { Schema } from "./schema";

function App() {
  const z = useZero<Schema>();
  const [contacts] = useQuery(    z.query.contacts.related("sales", (sales) => sales.one())  );
  const toggleNewsletter = (id: number, prev: boolean) => {
    z.mutate.contacts.update({
      id,
      has_newsletter: !prev,
    });
  };

  // If the initial sync hasn't been completed, these can be empty.
  if (!contacts.length) {
    return null;
  }

  return (
    <table border={1} cellSpacing={0} cellPadding={6} width="100%">
      <thead>
        <tr>
          <th>ID</th>
          <th>First Name</th>
          <th>Last Name</th>
          <th>Newsletter</th>
          <th>Sales</th>
        </tr>
      </thead>
      <tbody>
        {contacts.map((contact) => (
          <tr key={contact.id}>
            <td align="left">{contact.id}</td>
            <td align="left">{contact.first_name}</td>
            <td align="left">{contact.last_name}</td>
            <td>
              <input
                type="checkbox"
                checked={contact.has_newsletter}
                onChange={() => {
                  toggleNewsletter(contact.id, contact.has_newsletter);
                }}
              />
            </td>
            <td align="left">
              {contact.sales?.first_name} {contact.sales?.last_name}            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

export default App;

As you can see, the syntax is straightforward and explicit.

Embedding a related record with zero-crm

As you saw, we passed the <Schema> type parameter to useZero(). This allows all subsequent queries and mutations to be strongly typed. For instance, my IDE can autocomplete the fields on contacts.sales to suggest either first_name or last_name, and it will detect misspelled fields, which is safer and more convenient.

From this perspective, Zero also offers an excellent developer experience.

Filtering The List

Let's write one more query to see how we can filter the list based on that related record.

Let's add a toggle to choose between viewing all contacts and only "my contacts", i.e. the contacts I (as a sale) am in charge of.

We can then choose conditionally to add an extra WHERE clause to the query like so:

import { useQuery, useZero } from "@rocicorp/zero/react";
import { Schema } from "./schema";
import { SalesInput } from "./SalesInput.tsx";
import { useState } from "react";

export const ContactList = () => {
  const z = useZero<Schema>();
  const [onlyMine, setOnlyMine] = useState(false);  const contactsQuery = z.query.contacts.related("sales", (sales) =>    sales.one()  );  const [allContacts] = useQuery(contactsQuery);  const onlyMyContactsQuery = contactsQuery.whereExists("sales", (q) =>    q.where("user_id", "=", z.userID)  );  const [onlyMyContacts] = useQuery(onlyMyContactsQuery);  const contacts = onlyMine ? onlyMyContacts : allContacts;
  const toggleNewsletter = (id: number, prev: boolean) => {
    z.mutate.contacts.update({
      id,
      has_newsletter: !prev,
    });
  };

  const toggleOnlyMine = () => {    setOnlyMine((onlyMine) => !onlyMine);  };
  // If the initial sync hasn't been completed, these can be empty.
  if (!contacts.length) {
    return null;
  }

  return (
    <>
      <label htmlFor="onlyMyContacts">Only show my contacts</label>      <input        id="onlyMyContacts"        type="checkbox"        checked={onlyMine}        onChange={toggleOnlyMine}      />      <table border={1} cellSpacing={0} cellPadding={6} width="100%">
        <thead>
          <tr>
            <th>ID</th>
            <th>First Name</th>
            <th>Last Name</th>
            <th>Newsletter</th>
            <th>Sales</th>
          </tr>
        </thead>
        <tbody>
          {contacts.map((contact) => (
            <tr key={contact.id}>
              <td align="left">{contact.id}</td>
              <td align="left">{contact.first_name}</td>
              <td align="left">{contact.last_name}</td>
              <td>
                <input
                  type="checkbox"
                  checked={contact.has_newsletter}
                  onChange={() => {
                    toggleNewsletter(contact.id, contact.has_newsletter);
                  }}
                />
              </td>
              <td align="left">
                <SalesInput contact={contact} />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </>
  );
};

The key here was to leverage whereExists to write a condition depending on the related record. For more info, see Relationship Filters.

Adding a Permission Rule

Let's try adding a permission rule!

We'll add a rule enforcing that only the salesperson in charge of a contact can modify that contact.

If you remember what we explained earlier, permissions are defined in the schema.ts file.

Let's add an allowContactIfSalesMatch rule.

import {
  createSchema,
  createTableSchema,
  definePermissions,
  ExpressionBuilder,
  Row,
  ANYONE_CAN,
} from "@rocicorp/zero";

const salesSchema = createTableSchema({
  // ...
});

const contactsSchema = createTableSchema({
  // ...
});

export const schema = createSchema({
  version: 1,
  tables: {
    sales: salesSchema,
    contacts: contactsSchema,
  },
});

export type Schema = typeof schema;
export type Sale = Row<typeof salesSchema>;
export type Contact = Row<typeof contactsSchema>;

// The contents of your decoded JWT.
type AuthData = {
  sub: string | null;
};

export const permissions = definePermissions<AuthData, Schema>(schema, () => {  // allow to edit a contact if sales id matches 'sub' in the JWT  const allowContactIfSalesMatch = (    authData: AuthData,    { exists }: ExpressionBuilder<typeof contactsSchema>  ) => exists("sales", (q) => q.where("user_id", "=", authData.sub ?? ""));  return {    contacts: {      row: {        // anyone can insert        insert: ANYONE_CAN,        // only sales can edit their own contacts        update: {          preMutation: [allowContactIfSalesMatch],        },        // anyone can delete        delete: ANYONE_CAN,      },    },  };});

As you can see, permission rules also use ZQL.

With that rule in place, Zero immediately rejects any attempt to change the newsletter setting of a contact managed by another sale and immediately reverses the change.

We didn't implement any checks in the client to verify if the connected user is allowed to change the newsletter setting of a contact before letting them do it. This was intended because we wanted to confirm the server rejected the change.

Of course, in a real-world application, you would need to implement such a check on the client side too, to avoid flicker in the UI and user frustration.

It's interesting to note here that, to our knowledge (and at the time of writing), the Zero client provides no way of knowing that a mutation was rejected or reacting to the failure to display an error message. Instead, the UI just reverts the change with no further explanation, which can be confusing to users.

Takeaway: Our Zero Cents

In general, our experience with Zero was quite pleasant 😊.

The Developer Experience foremost was very good, with end-to-end type safety and useful JSDoc.

We also enjoyed reading through the documentation, especially the Quickstart guide and the hello-zero demo, which are well-designed to help us immediately grasp what makes Zero so interesting.

There are, however, numerous limitations that you have to consider if you plan on using Zero.

While they are usually clearly documented (and roadmapped!), some limitations can still be significant, depending on the complexity of the app you want to build and the tech stack you want to use. Here are some examples we noted:

  • Only Postgres is supported for now
  • Postgres views are not synced
  • Some Postgres column types aren’t supported, like array
  • The client API does not allow to react to errors or rejected updates

Also, we were surprised to find no section in the documentation treating conflict resolution. According to our tests, the reconciliation strategy seems very simple (last update wins), but this deserves to be discussed a little further.

The documentation regarding Zero itself (Zero client, Zero cache, ZQL, Permissions...) is quite good, however the documentation for the React API is currently really sparse and could be elaborated on.

In the end, leaving out the benefit of requiring no back-end, when compared to React Admin, which already offers almost the same features without having to install a server-side component, Zero only marginally improves the user experience, at the cost of a major change in the toolbelt (e.g., leaving TanStack Query, using a specific schema and query language, etc.).

Of course, we keep in mind that the project is still in the alpha stage, so all these issues might get resolved later. We sure hope they will, as we consider Zero to be very promising and will keep an eye on its future releases!

You can find the source code for zero-crm on my GitHub repo: https://github.com/marmelab/zero-crm

Did you like this article? Share it!