Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/cloudflare/partykit/llms.txt

Use this file to discover all available pages before exploring further.

partysync is a library for synchronizing typed records from a single Durable Object to a client in real time. You define a schema of record and action types, the server handles each action and returns changed records, and the client receives live updates — with optional optimistic updates that apply immediately before the server confirms.
partysync is experimental — the API and design are subject to change. It is not yet recommended for production use.

Installation

npm install partysync

When to use partysync

partysync is a good fit when:
  • You have one Durable Object per user or entity and want to stream its entire state to the client.
  • You want the guarantees Durable Objects provide: consistency, hibernation, and single-writer semantics.
It is not a good fit when:
  • The total state is too large to hold in the client’s memory.
  • You need to sync slices of a shared relational database (Postgres, MySQL). For those cases, consider zero, TinyBase, ElectricSQL, or similar.

Schema Definition

A partysync schema is a Record mapping channel names to a two-element tuple: [RecordType, ActionType]. RecordType is a tuple whose fields match the columns of your SQLite table. It must always start with an id string and end with a nullable deleted_at number:
// shared.ts

export type TodoRecord = [
  string,       // id        (required)
  string,       // text
  0 | 1,        // completed (SQLite boolean)
  number,       // created_at
  number,       // updated_at
  number | null // deleted_at (required)
];
ActionType is a discriminated union of every mutation the client can request:
export type TodoAction =
  | {
      type: "create";
      payload: {
        id: string;
        text: string;
        completed: 0 | 1;
      };
    }
  | {
      type: "update";
      // ...
    };

Class: SyncServer<Env, Schema>

Import from partysync. Extend SyncServer to create your Durable Object server.
import { SyncServer } from "partysync";
import type { TodoAction, TodoRecord } from "./shared";

export class MyServer extends SyncServer<
  Env,
  { todos: [TodoRecord, TodoAction] }
> {
  // ...
}
Env
type parameter
Your Worker environment bindings type.
Schema
Record<string, [RecordType, ActionType]>
Maps each channel name to its record type and action type tuple. You can define multiple channels in a single SyncServer.

onStart()

Called once when the Durable Object starts. Use it to create the SQLite tables that back your channels with this.ctx.storage.sql.exec.
onStart() {
  this.ctx.storage.sql.exec(
    `CREATE TABLE IF NOT EXISTS todos (
      id TEXT PRIMARY KEY NOT NULL UNIQUE,
      text TEXT NOT NULL,
      completed INTEGER NOT NULL,
      created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
      updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
      deleted_at INTEGER DEFAULT NULL
    )`
  );
}

onAction(channel, action)

Called whenever a client sends an action. Return the records that changed — partysync will broadcast them to all connected clients.
onAction(channel: keyof Schema, action: ActionType): RecordType[] | Promise<RecordType[]>
channel
keyof Schema
required
The channel the action was sent on, e.g. "todos".
action
ActionType
required
The action dispatched by the client. Matches the ActionType defined in your schema for the given channel.
Returns: An array of changed RecordType rows (or a Promise of one). Every returned record is broadcast to all clients subscribed to that channel.
onAction(channel: "todos", action: TodoAction) {
  switch (action.type) {
    case "create": {
      const { id, text, completed } = action.payload;
      return [
        ...this.ctx.storage.sql
          .exec(
            "INSERT INTO todos (id, text, completed) VALUES (?, ?, ?) RETURNING *",
            id,
            text,
            completed
          )
          .raw()
      ] as TodoRecord[];
    }
    // handle other action types...
  }
}

Hook: useSync

Import from partysync/react. Subscribes to a channel over a WebSocket and returns the current record list together with a function to dispatch actions.
import { useSync } from "partysync/react";

Signature

useSync<R, A>(
  channel: string,
  socket: WebSocket,
  optimisticUpdate?: (records: R[], action: A) => R[]
): [records: R[], sendAction: (action: A) => void]
channel
string
required
The channel name to subscribe to. Must match a key in the server’s Schema.
socket
WebSocket
required
An open WebSocket (e.g. from PartySocket) connected to the SyncServer.
optimisticUpdate
(records: R[], action: A) => R[]
Optional. Called immediately when sendAction is invoked, before the server confirms. Return the expected new state of the record array. If the server returns different data, the optimistic state is replaced.
Returns: A two-element tuple:
records
R[]
The current list of records for the channel, kept in sync with the server.
sendAction
(action: A) => void
Dispatches an action to the server. If optimisticUpdate is provided, the UI updates immediately while the server processes the action.

Full Working Example

Shared types (shared.ts)

export type TodoRecord = [
  string,       // id
  string,       // text
  0 | 1,        // completed
  number,       // created_at
  number,       // updated_at
  number | null // deleted_at
];

export type TodoAction =
  | {
      type: "create";
      payload: {
        id: string;
        text: string;
        completed: 0 | 1;
      };
    }
  | {
      type: "update";
      // ...
    };

Server (server.ts)

import { SyncServer } from "partysync";
import type { TodoAction, TodoRecord } from "./shared";

export class MyServer extends SyncServer<
  Env,
  { todos: [TodoRecord, TodoAction] }
> {
  onStart() {
    this.ctx.storage.sql.exec(
      `CREATE TABLE IF NOT EXISTS todos (
        id TEXT PRIMARY KEY NOT NULL UNIQUE,
        text TEXT NOT NULL,
        completed INTEGER NOT NULL,
        created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
        deleted_at INTEGER DEFAULT NULL
      )`
    );
  }

  onAction(channel: "todos", action: TodoAction) {
    switch (action.type) {
      case "create": {
        const { id, text, completed } = action.payload;
        return [
          ...this.ctx.storage.sql
            .exec(
              "INSERT INTO todos (id, text, completed) VALUES (?, ?, ?) RETURNING *",
              id,
              text,
              completed
            )
            .raw()
        ] as TodoRecord[];
      }
    }
  }
}

Client (client.tsx)

import { useSync } from "partysync/react";
import type { TodoAction, TodoRecord } from "./shared";

function TodoApp({ socket }) {
  const [todos, sendAction] = useSync<TodoRecord, TodoAction>(
    "todos",
    socket,
    // optional optimistic update
    (todos, action) => {
      switch (action.type) {
        case "create": {
          const { id, text, completed } = action.payload;
          return [
            ...todos,
            [id, text, completed, Date.now(), Date.now(), null]
          ];
        }
        default:
          return todos;
      }
    }
  );

  function handleCreate() {
    sendAction({
      type: "create",
      payload: { id: crypto.randomUUID(), text: "hello", completed: 0 }
    });
  }

  return (
    <div>
      <button onClick={handleCreate}>Add todo</button>
      <ul>
        {todos.map(([id, text]) => (
          <li key={id}>{text}</li>
        ))}
      </ul>
    </div>
  );
}
Records always use a tuple shape that mirrors your SQLite columns directly. The last field must always be deleted_at (a nullable number), and the first field must always be id (a string).

Build docs developers (and LLMs) love