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
- The user acts.
sync.tasks.update(id, { done: true })runs. The row changes on the screen at once. - The device queues it. The change is stored in an outbox on the device, so it survives a restart and a lost connection.
- The device sends it. When there is a network, the outbox goes to the server, in order.
- 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.
- 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:
Phone
Screen
Stored, as confirmed
Outbox
Empty
Server
Checks
Waiting
Log
- #41 tasks.create
- #42 projects.update
Laptop
Screen
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:
Screen
Outbox · 0
Empty
Confirmed up to #42
The row
☐ Book flights
The log, in the order writes arrived
Nothing yet after #42.
Screen
Outbox · 0
Empty
Confirmed up to #42
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:
Server
Members of each space
| Space | Ana | Ben |
|---|---|---|
| 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
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:
| Label | Runs | What it is |
|---|---|---|
| Shared | In the app and the server | The schema. It is a file both import, so it must not touch either side. |
| Client | On the device | createSync, the tables, queries, storage, status, and the UI bindings. |
| Server | In your backend | createSyncServer, the database store, auth and your rules. |
schema.tsimported by both sidesUI
sync.tasks.update(id, { done: true })Reads and writes, instantly
Local database
push
Pending writes, in order
pull · wait
What came after your last id
One route
createSyncServerChecks auth, the schema and your rulesApplies one change at a time
Your database
The three snippets below are the whole shape of an app. The schema is shared:
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:
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:
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.