Ferry
Examples

Cloudflare Workers

A Worker answers web Requests, so syncServer.fetch is the handler. There are three ways to run one:

D1, with a Durable Object

D1 has no transactions you can read inside, only batch(), which commits a fixed list of statements at once. Sync needs more: a push reads rows, checks them, hands out sync ids and writes, all as one transaction, and sync ids must form one gapless sequence per database. So one place writes: a Durable Object, the coordinator. The sync server runs in it, on d1Store, and the Worker sends it every sync request.

worker.tsServer
import { createSyncServer, d1Store, durableObjectCoordinator } from 'ferrysync/server';

import { schema } from './schema';

interface Env {
  DB: D1Database;
  SYNC: DurableObjectNamespace;
}

// The coordinator: the sync server runs here, so it is the database's one writer.
export class SyncCoordinator {
  readonly fetch: (request: Request) => Promise<Response>;

  constructor(state: DurableObjectState, env: Env) {
    const store = d1Store(env.DB);
    const syncServer = createSyncServer({
      schema,
      store,
      auth: async (request) => (await getSession(request))?.userId ?? null,
    });
    this.fetch = (request) => syncServer.fetch(request);
    // The tables and the sync-id counter, read before the first request is let in.
    void state.blockConcurrencyWhile(() => store.ready());
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (!new URL(request.url).pathname.startsWith('/sync/')) {
      return new Response('Not found', { status: 404 });
    }
    return durableObjectCoordinator(env.SYNC).fetch(request);
  },
};
// wrangler.jsonc
{
  "main": "worker.ts",
  "compatibility_date": "2025-09-01",
  "d1_databases": [{ "binding": "DB", "database_name": "sync", "database_id": "…" }],
  "durable_objects": { "bindings": [{ "name": "SYNC", "class_name": "SyncCoordinator" }] },
  // The object keeps nothing in its own storage: D1 holds everything.
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["SyncCoordinator"] }],
}

The store creates its tables in D1 on first use. To create them with wrangler d1 migrations instead, put the statements of syncTablesSql({ dialect: 'sqlite' }) in a migration and open the store with migrate: false.

How it stays correct

  • One transaction, one batch. While a push runs, its writes stay in the coordinator's memory, where its own reads see them. When it ends they go to D1 as one batch(): all of it commits, or none of it. A request that dies halfway commits nothing.
  • One at a time. The coordinator runs transactions one after another and keeps reads apart from commits, so sync ids come out gapless and every read sees one consistent state.
  • Nothing trusted from memory. Each batch starts with a guard, which fails it unless D1 is exactly as the coordinator last saw it. Each read checks the same once done. A coordinator that starts cold (after an eviction or a deploy) reads everything from D1.
  • Two coordinators lose cleanly. For a moment, an evicted object can still be finishing a request while its replacement serves new ones. If both write, the guard makes one of them fail with D1ConflictError and write nothing. The device sees a failed request and retries, and pushes are idempotent, so nothing is lost or applied twice.
  • Realtime for free. Every wait is held in the coordinator, so a write wakes the devices it concerns at once, with no broadcast to set up.

One coordinator per database

durableObjectCoordinator sends every request to the object named ferrysync. Use one name per D1 database, and never two for one: a second coordinator on the same database makes their writes fail each other, often. For many tenants with a database each, name each coordinator after its database:

Server
import { durableObjectCoordinator } from 'ferrysync/server';

declare const env: { SYNC: DurableObjectNamespace };
declare const tenant: string;
declare const request: Request;

const response = await durableObjectCoordinator(env.SYNC, { name: `tenant:${tenant}` }).fetch(
  request,
);

Check who the user is in the Worker too, if you can: a request turned away there never reaches the coordinator.

Limits

  • One object's throughput. Every write of a database goes through one coordinator, one at a time, and each takes a few round trips to D1 (reads, then the batch), so its writes per second are bounded by D1's latency. It is not measured yet; plan for tens to a few hundred pushes per second per database, not thousands (a device's pushes already come in batches). Past that, split the data into several databases, each with its coordinator.
  • D1's limits apply to each batch. The store keeps every statement under D1's 100 bound parameters and writes each row once per transaction, so a push of 500 changes is under a hundred statements. A row must still fit D1's row size, and a very large push its limit on queries per invocation: see D1's limits.
  • Held waits keep the object awake. A Durable Object is billed for the time it is active, and a held wait keeps it active. One coordinator per database means one object awake while devices are online, not one per device.
  • Read replicas. Pass the binding itself (env.DB), whose queries go to the primary. A replica may lag behind the coordinator's own commits.
  • Not supported on D1: within (joining your own transaction) and mirror tables, which need transactions D1 does not have.

Postgres

Two things differ from a long-running server: database connections live for one request, and work after the response needs ctx.waitUntil.

worker.tsServer
import { createSyncServer, memoryBroadcast, postgresJs, postgresStore } from 'ferrysync/server';
import postgres from 'postgres';

import { schema } from './schema';

interface Env {
  DATABASE_URL: string;
}

// One per isolate, shared by the requests it serves: a write wakes the waits this isolate holds.
const broadcast = memoryBroadcast();

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (!new URL(request.url).pathname.startsWith('/sync/')) {
      return new Response('Not found', { status: 404 });
    }
    // Workers share no connection between requests: a store, and so a server, per request.
    const sql = postgres(env.DATABASE_URL);
    const syncServer = createSyncServer({
      schema,
      store: postgresStore(postgresJs(sql), { migrate: false }),
      auth: async (incoming) => (await getSession(incoming))?.userId ?? null,
      broadcast,
      waitUntil: (promise) => ctx.waitUntil(promise),
    });
    const response = await syncServer.fetch(request);
    // Once its background work is done, the request's connections close.
    ctx.waitUntil(syncServer.close().then(() => sql.end()));
    return response;
  },
};

With Hono, the same goes inside the route: app.all('/sync/*', (c) => …) with c.env and c.executionCtx.

Configuration

postgres.js needs Node's APIs on Workers:

// wrangler.jsonc
{
  "main": "worker.ts",
  "compatibility_date": "2025-09-01",
  "compatibility_flags": ["nodejs_compat"],
}
  • The database: Postgres over Hyperdrive, which pools connections close to the database, or any Postgres reachable over TCP. Pass env.HYPERDRIVE.connectionString to postgres().
  • Create the tables in your migrations and open the store with migrate: false, since a store per request would otherwise check for migrations on every request: see Stores.

waitUntil

A Worker stops once its response is sent, unless work is handed to ctx.waitUntil. With waitUntil, the server runs afterCommit hooks and log retention after the response instead of before it, so writes answer sooner.

Realtime

A device holds a wait request open for up to 30 seconds. Workers allow it: a held request costs no CPU time while it waits.

A change wakes the waits held by the isolate that applied it, through the shared broadcast. Waits held by other isolates find it at their next look at the database, within 15 seconds. Changes always arrive; across isolates they take up to that long.

Gotchas

  • Never create the store at module level. A connection opened during one request cannot be used by another: the second request fails with "Cannot perform I/O on behalf of a different request".
  • Each request runs a few extra queries besides its own: the store checks its tables once, and retention looks at the log once per server, so here once per request. Both are small reads.
  • For D1, use the coordinator above: a store per request cannot keep D1's writes in order. A SQLite-backed Durable Object can also be the whole server.

Durable Objects

A SQLite-backed Durable Object can be the whole server: the store lives in the object's own database, with no Postgres and no connection to open. durableObjectSqlite(ctx.storage) is a driver for sqliteStore, so the tables, the SQL and the migrations are the SQLite store's own.

sync-object.tsServer
import { createSyncServer, durableObjectSqlite, sqliteStore } from 'ferrysync/server';
import type { DurableObjectSqliteStorage } from 'ferrysync/server';

import { schema } from './schema';

interface ObjectState {
  storage: DurableObjectSqliteStorage;
  waitUntil(promise: Promise<unknown>): void;
}

export class SyncObject {
  private readonly syncServer;

  constructor(ctx: ObjectState) {
    // The object is created once, and again after an eviction: the store reads its state from
    // the object's database, so nothing is lost.
    this.syncServer = createSyncServer({
      schema,
      store: sqliteStore(durableObjectSqlite(ctx.storage)),
      // The Worker authenticated the request before it forwarded it.
      auth: async (request) => request.headers.get('x-user-id'),
      waitUntil: (promise) => ctx.waitUntil(promise),
    });
  }

  fetch(request: Request): Promise<Response> {
    return this.syncServer.fetch(request);
  }
}

The Worker picks the object by name and forwards the request:

worker.tsServer
interface ObjectNamespace {
  idFromName(name: string): unknown;
  get(id: unknown): { fetch(request: Request): Promise<Response> };
}

interface Env {
  SYNC: ObjectNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const session = await getSession(request);
    if (session === null) {
      return new Response('Unauthorized', { status: 401 });
    }
    const forwarded = new Request(request);
    forwarded.headers.set('x-user-id', session.userId);
    return env.SYNC.get(env.SYNC.idFromName('app')).fetch(forwarded);
  },
};
// wrangler.jsonc
{
  "main": "worker.ts",
  "compatibility_date": "2025-09-01",
  "durable_objects": { "bindings": [{ "name": "SYNC", "class_name": "SyncObject" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["SyncObject"] }],
}

What an object is keyed by

A store hands out sync ids without gaps, and one object holds one store, so one object is one sync authority: every user and every space whose data must be in one log lives in the same object. Users, memberships and spaces cannot be split across objects, and nothing queries across them.

  • One object for the whole app (idFromName('app')) is right for a small app or a prototype, and the simplest: every user can share any space.
  • One object per organization or workspace (idFromName(orgId)) scales out: each team is its own authority. A user in two organizations then has two sync servers, and the app connects to each one separately.
  • One object per user only fits data that is never shared: a space with several members cannot span two objects.

Limits

  • Storage: a SQLite-backed object has a storage cap (10 GB at the time of writing: check Cloudflare's limits). The log and every record live in it, so size the key for the largest tenant, and keep retention on.
  • Throughput: an object runs one thing at a time, and the store runs one transaction at a time on top of it. Writes per second are those of a single SQLite database on one thread, shared by every user of the key.
  • One location: an object runs in one place. Devices far from it pay the round trip.
  • Realtime: every write goes through the object, so a held wait is woken at once.
  • No mirrors: mirroring into your own tables needs a database you also query from elsewhere: use Postgres (D1 does not support mirrors either).
  • Hooks that call fetch release the object to other requests while they wait; the store still runs its transactions in order.

On this page