Skip to main content
Zero’s sync model is built on three core principles: real-time replication from PostgreSQL, incremental view maintenance for efficiency, and optimistic client updates for responsiveness.

Overview

Zero implements a multi-tier sync model where:
  1. PostgreSQL streams changes via logical replication
  2. zero-cache maintains incremental views in SQLite
  3. Clients receive fine-grained updates over WebSocket

PostgreSQL Logical Replication

Zero uses PostgreSQL’s native logical replication to stream data changes from the source database.

How It Works

1

Publication Setup

Zero creates a PostgreSQL publication for the tables you want to sync:
CREATE PUBLICATION zero_publication FOR TABLE issue, comment, user;
2

Replication Slot

A replication slot ensures zero-cache doesn’t miss changes:
SELECT * FROM pg_create_logical_replication_slot(
  'zero_slot', 
  'pgoutput'
);
3

Change Stream

PostgreSQL sends a stream of changes (INSERTs, UPDATEs, DELETEs) to zero-cache.
4

Watermark Tracking

zero-cache tracks its position in the replication stream via watermarks to handle restarts.

Change Processing

The IncrementalSyncer processes the replication stream:
// From packages/zero-cache/src/services/replicator/incremental-sync.ts
export class IncrementalSyncer {
  async run(lc: LogContext) {
    while (this.#state.shouldRun()) {
      const {replicaVersion, watermark} = getSubscriptionState(this.#replica);
      
      downstream = await this.#changeStreamer.subscribe({
        protocolVersion: PROTOCOL_VERSION,
        watermark,        // Resume from last processed position
        replicaVersion,
      });
      
      for await (const message of downstream) {
        // Process each replication message
        const result = processor.processMessage(lc, message);
        
        if (result?.watermark && result?.changeLogUpdated) {
          // Notify clients when new version is ready
          void this.#notifier.notifySubscribers({state: 'version-ready'});
        }
      }
    }
  }
}

Replication Guarantees

Changes are delivered at least once. zero-cache is idempotent and handles duplicates.
Changes within a PostgreSQL transaction arrive in order.
The order of transactions may differ from PostgreSQL commit order.
zero-cache persists its replication position. After restart, it resumes from the last watermark.

Incremental View Maintenance (IVM)

Instead of re-executing queries on every change, Zero uses Incremental View Maintenance to efficiently update query results.

The IVM Pipeline

Queries are compiled into a pipeline of operators that maintain materialized views:

Change Types

The IVM engine processes four types of changes:
// From packages/zql/src/ivm/change.ts
export type Change = AddChange | RemoveChange | ChildChange | EditChange;

// A node (row + relationships) being added
export type AddChange = {
  type: 'add';
  node: Node;
};

// A node being removed
export type RemoveChange = {
  type: 'remove';
  node: Node;
};

// A child relationship changing
export type ChildChange = {
  type: 'child';
  node: Node;
  child: {
    relationshipName: string;
    change: Change;
  };
};

// A row's data changing (PK may change)
export type EditChange = {
  type: 'edit';
  node: Node;
  oldNode: Node;
};

Operator Interface

Each operator in the pipeline implements:
// From packages/zql/src/ivm/operator.ts
export interface Input {
  // Fetch initial data
  fetch(req: FetchRequest): Stream<Node | 'yield'>;
  
  // Receive and transform change
  push(change: Change, pusher: InputBase): Stream<'yield'>;
  
  // Set where to send output
  setOutput(output: Output): void;
}

export interface Output {
  // Push transformed change downstream
  push(change: Change, pusher: InputBase): Stream<'yield'>;
}

Example: Filter Operator

When a row changes, the filter operator decides whether to add, remove, or pass through:
// Simplified filter logic
class FilterOperator implements Input, Output {
  push(change: Change): Stream<'yield'> {
    switch (change.type) {
      case 'add':
        // Check if new row matches filter
        if (this.condition(change.node.row)) {
          yield* this.output.push(change);
        }
        break;
        
      case 'remove':
        // If row was in view, remove it
        if (this.storage.has(change.node)) {
          yield* this.output.push(change);
        }
        break;
        
      case 'edit':
        const wasMatch = this.condition(change.oldNode.row);
        const isMatch = this.condition(change.node.row);
        
        if (wasMatch && isMatch) {
          // Still matches, pass through edit
          yield* this.output.push(change);
        } else if (wasMatch && !isMatch) {
          // No longer matches, convert to remove
          yield* this.output.push({type: 'remove', node: change.oldNode});
        } else if (!wasMatch && isMatch) {
          // Now matches, convert to add
          yield* this.output.push({type: 'add', node: change.node});
        }
        break;
    }
  }
}

Performance Benefits

O(changes) not O(data)

Work is proportional to the number of changes, not the size of the database.

Fine-grained updates

Clients receive only the specific rows that changed.

Shared computation

Multiple clients subscribed to the same query share the IVM pipeline.

Lazy evaluation

Operators yield control to avoid blocking on large transactions.

Client Sync Protocol

Clients sync with zero-cache using a custom protocol over WebSocket.

Connection Lifecycle

Pull Protocol

Clients pull updates from the server:
// Client sends its current version
type PullRequestMessage = {
  pullVersion: number;
  profileID: string;
  clientGroupID: string;
  cookie: NullableVersion;  // Last known server version
};

// Server responds with patch
type PullResponseMessage = {
  cookie: NullableVersion;  // New server version
  lastMutationIDChanges: Record<ClientID, number>;
  patch: Patch[];  // Changes since client's version
};

Push Protocol

Clients push mutations to the server:
// Client pushes mutations
type PushMessage = {
  profileID: string;
  clientGroupID: string;
  mutations: Array<CRUDMutation | CustomMutation>;
};

// CRUD mutation example
type CRUDMutation = {
  id: MutationID;
  name: 'zero:crud';
  args: [
    {
      tableName: string;
      op: 'insert' | 'update' | 'delete';
      value: Record<string, unknown>;
    },
  ];
};

Poke Mechanism

“Pokes” notify clients that data has changed:
type PokeStartMessage = {
  type: 'pokeStart';
  baseCookie: NullableVersion;
  pokeID: string;
};

type PokePartMessage = {
  type: 'pokePart';
  pokeID: string;
  clientsPatch: ClientsPatch[];
};

type PokeEndMessage = {
  type: 'pokeEnd';
  pokeID: string;
  cookie: NullableVersion;
};
When data changes:
  1. Server sends pokeStart with the transaction ID
  2. Server sends one or more pokePart messages with patches
  3. Server sends pokeEnd with the final version
  4. Client pulls to get its personalized view

Client-Side Behavior

Optimistic Updates

Clients apply mutations optimistically before server confirmation:
// Mutation applies immediately to local IndexedDB
await zero.mutate.updateIssue({
  id: 'issue-123',
  title: 'Updated title',  // Shows immediately in UI
});

// Meanwhile:
// 1. Mutation queued for push
// 2. Pushed to server over WebSocket
// 3. Server validates and executes
// 4. Replication streams back to zero-cache
// 5. zero-cache pokes client
// 6. Client pulls and reconciles

Reconciliation

When the server’s authoritative version arrives:
1

Client receives poke

Server notifies client that data changed.
2

Client pulls

Client requests the new state with a PullRequest.
3

Server computes view

Server evaluates queries with permissions for this specific user.
4

Client receives patch

Client gets only the changes since its last known version.
5

Replicache reconciles

Replicache merges the authoritative state with pending optimistic mutations.

Conflict Resolution

Zero uses last-write-wins at the field level:
  • Server version always wins for committed data
  • Pending optimistic mutations are replayed on top
  • If optimistic mutation conflicts with server, it fails and UI reverts
// Example: Two clients update the same issue

// Client A:
await zero.mutate.updateIssue({id: 'issue-1', title: 'New Title A'});

// Client B (simultaneously):
await zero.mutate.updateIssue({id: 'issue-1', title: 'New Title B'});

// Result: Whichever mutation reaches PostgreSQL first wins.
// The other client's optimistic update is replaced by the server's version.
For complex conflict resolution, implement it in a server-side mutator where you can use transactions and business logic.

Consistency Guarantees

Transactional Consistency

  • Single-object writes: Always atomic
  • Multi-object writes: Use server-side mutators with transactions
  • Cross-client consistency: Eventually consistent (typically < 100ms)

Causality

Zero preserves causality within a client session:
  • A client always sees its own writes
  • A client never sees a future state before an earlier state
  • Cross-client causality is not guaranteed (use version numbers if needed)

Ordering

Mutations from a single client are applied in order.
No global ordering. Use PostgreSQL’s TIMESTAMP or sequences for ordering.
Clients always read their own optimistic mutations.

Performance Characteristics

Latency

OperationLatency
Local read (IndexedDB)< 1ms
Optimistic mutation< 1ms
Server mutation confirmation50-200ms
Cross-client propagation50-300ms
PostgreSQL → zero-cache10-50ms
zero-cache → client10-50ms

Throughput

  • Replication: 10,000+ changes/sec per zero-cache instance
  • Client syncs: 100+ queries per client with shared IVM pipelines
  • Mutations: Limited by PostgreSQL write capacity

Bandwidth

  • Initial sync: Full query results
  • Updates: Only diffs (typically 10-100x smaller than full results)
  • Compression: WebSocket supports compression

Offline Support

Clients work offline and sync when reconnected:
const zero = new Zero({
  server: 'https://your-server.com',
  schema,
  userID,
  // Mutations are queued locally
  // Queries read from IndexedDB
});

// Works offline:
const issues = useQuery(zero.query.issue.where('status', 'open'));

await zero.mutate.createIssue({
  id: nanoid(),
  title: 'Created offline',
  status: 'open',
});

// When online, mutations sync automatically
Offline mutations are queued and executed in order when connectivity is restored. Failed mutations (e.g., permission denied) are reported via PromiseWithServerResult.

Backpressure and Flow Control

Zero implements backpressure to prevent overwhelming clients:
  • Client-side: Replicache buffers updates if UI is busy
  • Server-side: zero-cache tracks memory usage and pauses replication if needed
  • Yielding: IVM operators yield control during large transactions
// Operators yield to avoid blocking
export interface Input {
  fetch(req: FetchRequest): Stream<Node | 'yield'>;
}

// Consumer must handle 'yield' and give control back periodically
for (const item of stream) {
  if (item === 'yield') {
    await sleep(0);  // Let other tasks run
  } else {
    // Process node
  }
}

Next Steps

Architecture

Understand the three-tier architecture

Queries

Learn how to build efficient queries

Schema

Define your data model

Permissions

Control who can access what data

Build docs developers (and LLMs) love