Offline-first sync for TypeScript

Apps that never wait for the network

Ferry keeps a copy of your data on each device, so every read and write is instant and works offline. It syncs that copy with your own server and database in the background. A TypeScript library you install, not a service you rent.

Get startedHow it works

Offline edits wait on the device and merge per field when it reconnects.

Try: Press Set up a conflict, then bring both devices back online.

Phone

synced

Your server

source of truth
  • Book flights
  • Pack bags

Log, newest first

  1. Nothing yet.

Laptop

synced

Each device edits its own copy, instantly. Offline changes wait in a queue and go up when the connection is back.

A simulation that runs in your browser and follows Ferry's rules. Nothing is sent anywhere.

What it is for

Three problems it removes, all in the demo above

A spinner on every screen

Reads and writes hit a database on the device, so a tap shows its result at once. The network is never in the way.

Try: Edit a task above: it changes before anything leaves the device.

An app that is useless offline

Changes wait in a queue on the device, survive a restart, and go up in order when the connection is back.

Try: Press Go offline, keep editing, then bring it back.

Sync and conflict code written by hand

The server puts every change in one order and merges per field. Two devices editing different fields both keep their edit.

Try: Press Set up a conflict and bring both devices back.

Why Ferry

Why pick it over the alternatives

What Ferry gives you

  • Runs inside your backend and your database, as a library on one route. No hosted service, no vendor.
  • One Zod schema defines the client, the server and the types.
  • Per-field merge, and transactions that undo as one step.
  • Any runtime and framework: Node, Bun, Deno, Workers, browsers and React Native (no native code, Hermes-safe).
  • Tested with a seeded multi-device simulator, so a failing sync scenario replays exactly.

The alternatives, and when they are the better pick

Sync code by hand

You build the outbox, ordering, retries, rollback and conflict rules, and test them.

Better when The sync is one narrow case with no offline writes.

Hosted backend: Firestore

Offline persistence is built in. Your data lives in Google’s service.

Better when You want a fully managed backend and no server of your own.

Replicache

A client-side sync framework for your own backend. It is in maintenance mode and points users to Zero.

Better when You already run it: follow its migration path.

Zero

A local client datastore where the queries in your app decide what syncs.

Better when A client needs only a slice of a huge dataset. Ferry syncs whole spaces.

PowerSync

A local SQLite database synced from Postgres, MongoDB, MySQL or SQL Server. PowerSync Cloud, or self-hosted.

Better when Your data is in MongoDB, MySQL or SQL Server, or you want SQLite on the device. Ferry stores are Postgres and SQLite.

Electric

An Apache 2.0 sync engine that streams Postgres to clients over HTTP. Electric Cloud, or next to your own API.

Better when You want a sync service in front of an existing Postgres.

Descriptions come from each project's own site. Check them: these tools change.

0.4 ms
for create() to return, with the new row already visible (10,000 rows on the device)
1.7 ms
for a write to be confirmed by the server, in process
65 ms
for 1,000 writes made offline to go up once back online, in process
Benchmarks: no network, the cost of Ferry itself

The code

A schema, a client and a server

Describe your tables once with Zod and both sides import it. The server mounts Ferry on one route, over your auth and your database. The client points at that route. Leave the server out and the app still works, with the device as the only authority.

Build it step by step
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),  }),});
import { createSyncServer, nodeSqlite, sqliteStore } from 'ferrysync/server';import { DatabaseSync } from 'node:sqlite';import { schema } from './schema';export const server = createSyncServer({  schema,  store: sqliteStore(nodeSqlite(new DatabaseSync('sync.db'))),  auth: async (request) => (await getSession(request))?.userId ?? null,});// Hono, Bun.serve, Workers, a Next.js route…export const handle = (request: Request) => server.fetch(request);
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' }),});// Instant, offline-safe, and synced in the background.const task = sync.tasks.create({ title: 'Book flights' });sync.tasks.update(task.id, { done: true });sync.undo(); // and the user can take it back

Shared · Imported by both

Fit

Who it is for, and who it is not

Use Ferry when

  • The app must work offline, or feel instant on a slow network.
  • Several devices or people edit the same data, and the last edit of a field winning is an acceptable answer to a conflict.
  • What 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

  • You want a hosted backend. Ferry is a library in your server, not a service.
  • People edit the same long text at once and expect the edits to merge. That needs a CRDT.
  • 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.
Why it works this way

Start with no backend at all

The device holds the data from the first line. Add a server and accounts later, without changing the app.