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:
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
- #43 phone: title → “Flights to Tokyo”
- #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.
The rules
| Situation | Result |
|---|---|
| Two devices edit different fields | Both edits are kept |
| Two devices edit the same field | The write the server applied last wins |
| One edits a row another deleted | The delete wins: the edit is rejected, and the row stays deleted |
| One creates an id that is still alive | Rejected, because the row already exists |
| One creates the id of a deleted row | The 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.
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
- Overview: how a change travels, with the playground.
- Status and offline: showing pending and offline in the app.
- Spaces: who can see and write a row at all.
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.
useQuery or a state library?
Ferry's data is on the device, so any UI can read it. There are four ways to do that, and they are not rivals: they read the same local database. The difference is where your app already keeps its state.