Ferry

Overview

Ferry is a sync engine you add to an app you already have. The app keeps its data on the device and works from there; Ferry keeps that data in step with your backend, in the background.

This page is the map: the idea behind Ferry, how a change travels, and what runs where.

The idea

The device is where the app lives. Reads and writes go to a local database, so they are instant, and they work with no network. There is no loading state to design, no spinner between a tap and the result, and no "you are offline" screen.

The server decides the order. Every change a device makes becomes a transaction. The server applies transactions one at a time and writes each result to a log with an increasing id. A device catches up by asking for what came after the last id it saw. Because one place orders everything, every device ends up with the same data.

You own the backend. Ferry is a library you mount in your server, on one route, over your own database and your own auth. It is not a service to sign up for, and your data never goes through anyone else.

One definition, everywhere. You describe your tables once, with Zod. The client, the server and the TypeScript types all come from that schema, so they cannot disagree.

Private by default. Every row belongs to the user who created it, until the schema puts a table in a space: a group of rows some users share, such as a project or a team. A user receives exactly the spaces they belong to, and nothing else.

Note

Space and group mean the same thing. "Space" is the word of the app-facing API (spaces in defineSchema); "group" is what the engine, the wire protocol and the internals docs call it.

Small and pluggable. A schema, a storage and a backend: each is one option, with a default. The storage can be memory, IndexedDB, SQLite or your own. The client is plain TypeScript, so any UI reads it: React hooks, Zustand, Jotai and Legend-State are built in, and Vue, Svelte or vanilla JS take a few lines (your own bindings). The server can run on Node, Bun, Deno or Workers, over SQLite or Postgres.

Boring conflicts, on purpose. When two devices edit the same field, the last write the server sees wins, field by field. That is easy to predict and to explain, and it covers most apps. If you need people to merge edits to the same long text, you need a CRDT for that field, and Ferry will not pretend otherwise. When two devices edit the same thing has the rules, with a demo.

How a change travels

  1. The user acts. sync.tasks.update(id, { done: true }) runs. The row changes on the screen at once.
  2. The device queues it. The change is stored in an outbox on the device, so it survives a restart and a lost connection.
  3. The device sends it. When there is a network, the outbox goes to the server, in order.
  4. The server decides. It checks the schema and your rules, applies the change, and appends it to the log. If it refuses, the device rolls the change back and tells your app.
  5. Everyone catches up. Other devices pull what came after their last id and update. Their own pending changes are replayed on top, so a server change never makes the screen flicker.

Step through it, and switch on a refusal to see the rollback:

InteractiveHow a change travels

Phone

Screen

Book flights

Stored, as confirmed

Book flights

Outbox

Empty

push
confirmed

Server

Checks

Waiting

Log

  1. #41 tasks.create
  2. #42 projects.update
pull after #42

Laptop

Screen

Book flights

Seen up to

#42

0 / 5Before. Both devices and the server agree: the task is not done, and both devices have seen the log up to #42.

The device stores only what the server confirmed. What the user sees is that confirmed data plus their pending changes, computed in memory. That is why a refused change can simply disappear.

Try it: offline, conflicts and catching up

Two devices of one user, one task, and the server between them. Take a device offline, edit on both, and bring them back in the order you choose:

InteractiveTwo devices, one server
Phone

Screen

Book flights
title: confirmeddone: confirmed

Outbox · 0

Empty

Confirmed up to #42

Server

The row

☐ Book flights

The log, in the order writes arrived

Nothing yet after #42.

Laptop

Screen

Book flights
title: confirmeddone: confirmed

Outbox · 0

Empty

Confirmed up to #42

Try it: take both devices offline, rename the task on each, tick it on one, then bring the laptop back first and the phone second. The phone's name wins, because it reached the server last; the tick survives, because it was another field.

What it shows:

  • Edits never wait. Offline or not, a change shows at once and waits in the device's outbox.
  • The server's order decides. Writes are applied in the order they arrive; for the same field, the last one wins. Writes to different fields both survive.
  • Pending changes are replayed on top. A device that hears news from the server while it still has pending writes shows the news with its own writes over it, until they land too.

Spaces: who sees what

Rows belong to the user who created them, or to a space the schema puts them in. Change who is in which space:

InteractiveWho sees what

Server

Members of each space

SpaceAnaBen
project:japan
project:work

Every row it holds

  • Book flightsproject:japan
  • Visaproject:japan
  • Quarterly planproject:work
  • Ana’s notesprivate to Ana
  • Ben’s notesprivate to Ben

Ana's device

Receives 4 of 5

  • Book flights
  • Visa
  • Quarterly plan
  • Ana’s notes

Ben's device

Receives 3 of 5

  • Book flights
  • Visa
  • Ben’s notes
Add Ben to Work, or take Ana out of Japan trip, and watch their devices. A device receives exactly its user's spaces and private rows; leaving a space removes its rows from the device.

Who runs what

Ferry has three kinds of code. Every code block in these docs says which one it is, with a label on top of the block:

LabelRunsWhat it is
SharedIn the app and the serverThe schema. It is a file both import, so it must not touch either side.
ClientOn the devicecreateSync, the tables, queries, storage, status, and the UI bindings.
ServerIn your backendcreateSyncServer, the database store, auth and your rules.
schema.tsimported by both sides
DeviceClient

UI

Any UI: React, Vue, Svelte, vanilla…sync.tasks.update(id, { done: true })

Reads and writes, instantly

Local database

Confirmed rowsOutbox: pending writes

push

Pending writes, in order

pull · wait

What came after your last id

Your backendServer

One route

createSyncServerChecks auth, the schema and your rules

Applies one change at a time

Your database

Rowslog41 42 43…
The device works on its own copy. The network only carries changes, and the server's log decides their order.

The three snippets below are the whole shape of an app. The schema is shared:

schema.tsShared
import { defineSchema } from 'ferrysync';
import { z } from 'zod';

export const schema = defineSchema(
  { tasks: z.object({ id: z.string(), title: z.string(), done: z.boolean().default(false) }) },
  // No spaces: every task is private to the user who created it.
);

The server mounts Ferry on one route, over your own auth:

server.tsServer
import { createSyncServer, memoryStore } from 'ferrysync/server';

import { schema } from './schema';

export const server = createSyncServer({
  schema,
  store: memoryStore(), // use sqliteStore or postgresStore in production
  auth: async (request) => (await getSession(request))?.userId ?? null,
});

// Any framework that speaks Request and Response: Hono, Bun.serve, Workers, a Next.js route…
export const handle = (request: Request) => server.fetch(request);

The client points at that route:

sync.tsClient
import { createSync, http, indexedDbStorage } from 'ferrysync';

import { schema } from './schema';

export const sync = createSync({
  schema,
  storage: indexedDbStorage('my-app'),
  backend: http({ url: 'https://api.example.com/sync' }),
});

Leave out backend and the client runs on its own, with the device as the only authority. That is a fine way to start: add the server and an account later, without changing the app.

When to use it

Use Ferry when:

  • the app must work offline, or should feel instant on a slow network;
  • several devices or people edit the same data, and "the last edit of a field wins" is an acceptable answer to a conflict;
  • the data one user can see fits in memory on their device;
  • the data falls into clear sharing units (a team, a workspace, a document).

Look elsewhere when:

  • people edit the same long text at once and expect the edits to merge;
  • a write must be approved by the server before the user sees it, such as a payment;
  • a client needs only a slice of a huge shared dataset.

Next steps

On this page