Ferry
Concepts

When two devices edit the same thing

Two people, or two devices of one person, can change the same task while both are offline. When they reconnect, Ferry has to pick one result. It uses one rule, the same for every table: the server applies writes in the order they reach it, and for each field the last one applied wins.

There are no merge functions to write and no conflict dialogs to build. The rule is small enough to predict, and the rest of this page is how to live with it.

Try it

Pick a field for each device, then who reaches the server first. Edit the same field on both and watch one edit overwrite the other; edit different fields and both survive:

InteractiveTwo edits, one task

Phone edits

title → “Flights to Tokyo”

Laptop edits

title → “Flights to Osaka”

Reaches the server first

Order of arrival decides a clash.

The server’s row

  1. #43 phone: title → “Flights to Tokyo”
  2. #44 laptop: title → “Flights to Osaka”

[ ] Flights to Osaka

Same field: the laptop’s write was applied last, so it wins. The other edit is not merged, it is overwritten.

Both devices were offline and come back. The server applies the writes in the order they reach it. Each write carries only the field it changed.

The rules

SituationResult
Two devices edit different fieldsBoth edits are kept
Two devices edit the same fieldThe write the server applied last wins
One edits a row another deletedThe delete wins: the edit is rejected, and the row stays deleted
One creates an id that is still aliveRejected, because the row already exists
One creates the id of a deleted rowThe row comes back (this is how undo restores a delete)

Two details make this friendlier than it sounds:

  • An update carries only the fields that really changed. A form that saves every field does not overwrite a teammate's edit of a field the user never touched.
  • "Last" means last at the server, not last on the clock. A device that was offline for an hour and syncs now has its writes applied now, after everything that arrived before them. Device clocks are never compared.

What the user sees

Each device shows what the server confirmed, with its own pending writes on top. When news from another device arrives, the screen shows the news with the device's pending writes replayed over it, so a field the user just edited does not flicker back. Once the server applies the write, the screen shows the final result, which may be another device's value if theirs landed later.

A write the server refuses (a deleted row, a rule of yours) is rolled back on the device, and onRejected tells the app why.

Model your data for it

Last-writer-wins is a good fit for most fields: titles, flags, statuses, assignees. It is a poor fit for values that two people change by adding to them:

  • A counter that two devices increment: store one row per increment and count the rows. Two increments of one field would overwrite each other.
  • A list two people append to: store the items as rows, not as an array inside one field.
  • A long text edited by several people at once: Ferry does not merge text. If that is the heart of your app, use a CRDT for that field.
schema.tsShared
import { defineSchema } from 'ferrysync';
import { z } from 'zod';

export const schema = defineSchema({
  // One row per vote, so two devices voting at once both count.
  votes: z.object({ id: z.string(), pollId: z.string(), choice: z.string() }),
});

Next

On this page