Blog
How Gravity Sync keeps both versions of your note
Gravity Sync is out now as a free beta in Gravity for iPhone, iPad, Android and Mac. It puts one list of notes on every device you sign in on. This post is about the part that took longest: what the server does when two devices change the same note while neither can see the other.
The rule I built everything around is that text you wrote never disappears because of sync. A note can change, sink, get archived or get deleted on purpose. It can't vanish because two phones disagreed about what happened while one was in a tunnel.
Why not last writer wins
Most sync engines settle conflicts by timestamp. Each device sends its version of a record, the server keeps the newest, and every device converges on the same answer. Converging is easy. The trouble is what converging throws away.
Say you fix a typo in a note on your Mac while your phone is offline, and on the phone you add a paragraph to the same note. Last writer wins keeps whichever arrives later. If that's the typo fix, your paragraph is gone, and nothing on either screen tells you. Phone clocks drift too, so "later" can mean the device that's set five minutes fast.
Line-level merging does better until both devices touch the same line, and then it picks a winner as well. A notes app gets plenty of those, because most notes are one line.
So Gravity Sync gives up a little tidiness for safety. When two edits to the same text can't both be the current version, it keeps both: one stays as the note and the other sits next to it as a copy marked Conflict. You merge them by hand, which takes ten seconds, and you never have to wonder where a paragraph went.
One database per library
The server is a Cloudflare Worker with one SQLite-backed Durable Object per library. A Durable Object is a small single-threaded server with its own storage, so every change to your library goes through one queue and one database, one at a time. Two phones pushing at the same moment get an order, and each change sees exactly the state the one before it left behind.
The plan was bigger at first. In September I designed sync around PowerSync and a Neon Postgres database. Partway through I replaced both with this, because a single SQLite database per person maps onto the problem directly. Nobody shares a library, there's no cross-library query to make fast, and transactions are local and synchronous. Accounts live in D1, sign-in is Clerk, and each library is backed up to R2 every night.
Operations, not states
Devices don't upload "here's the note now". They upload what the person did. There are nine kinds of operation:
- create, with the note's first text
- text, with the full new text and the revision it was edited from
- bump and rescue, which move a note to the top
- archive and unarchive
- trash, restore and purge (delete for good)
Sending intent instead of state lets the server merge by meaning. If you archive a note on your phone while you edit it on your Mac, those don't conflict, because one changes where the note lives and the other changes what it says, and both apply. If you rescue the same note on two devices, its rescue count goes up by two, because you rescued it twice.
A text operation always carries the whole note and the revision it started from, never a patch. When it arrives, the server compares that base revision with the note's current one:
- If they match, nobody else got there first. The new text becomes the note and the revision moves on.
- If they don't, another device changed the note in the meantime. Its text stays as the note, and the arriving text becomes a copy labelled Conflict.
- If the note was purged, a device that saw the deletion can't bring the text back, and a device that wrote something new before it heard about the deletion keeps that text as a copy marked "deleted on another device".
Saving and syncing are two different promises
Gravity's whole point is that capture is instant, so sync has to stay out of the way of the editor. Every write on the device stores the note and its sync operation in the same SQLite transaction. Either both are on disk or neither is. If the app crashes between "you pressed Return" and "the phone found a network", the operation is still there when it starts again.
The status in Settings keeps the two promises apart. "Changes saved on this device" means your text is safe on this phone. "Up to date" means the server has it too. The app never shows the second while work is still queued.
Retrying without doing things twice
Phones lose responses all the time. The server commits your change, the reply dies in a lift, and the phone has no idea whether it worked. It has to retry, and the retry mustn't rescue the note a second time.
Every operation carries a mutation ID made on the device. The server stores a receipt for each one in the same transaction as the change itself, with a hash of the payload. A retry with the same ID and the same payload gets back the stored result without running again. The same ID with a different payload is refused, because something has gone wrong and guessing would be worse.
Knowing text without keeping it
Joining sync is where many systems duplicate everything. You've got 3,000 notes on your iPhone through iCloud and the same 3,000 on your Mac. Turn on Gravity Sync on the iPhone, then on the Mac, and a naive server ends up with 6,000.
Gravity's server checks whether it has seen a piece of text before, even text it no longer stores. For every revision a note has held, it keeps a keyed hash of the text, using HMAC-SHA256 with keys that never leave the server. When the Mac joins and offers a note, the server checks the hash. If the note has held that exact text at any point, now or earlier, the Mac's copy adds nothing. If the text is new, it becomes a Conflict copy. The server knows "I've had this text" without storing old versions of your notes.
Order comes along too. Each note keeps its creation time and its place in the list when it joins, capped at the server's clock, so the list on the new device looks the way you left it.
Trying to break it
A bug in merge code doesn't crash anything. It loses a sentence somebody wrote in March, and they notice in June. I wanted tests that went looking for that.
The merge core is a pure TypeScript module with no network, no clock and no randomness of its own. The server passes in the time, the ID generator and the hashes. That makes it cheap to run thousands of times:
- It runs against an in-memory store and against the real SQLite schema with its triggers, and both must agree.
- 21 recorded concurrency cases from early prototypes replay on every check.
- A property-based simulator runs three devices doing all nine operations with offline stretches, reordered delivery, lost responses and retries. After every run it checks one rule: every text anyone committed still exists as a note, a copy or a queued operation, unless someone deliberately replaced or deleted it on a device that could see it.
- The same runs with skewed clocks have to reach the same merge results.
- Mutation testing switches off individual merge rules in a scratch copy and requires the tests to fail. A rule no test notices missing isn't really tested. That run takes about half an hour, and I run it after every change to the merge code.
What it costs
Speed mattered as much as safety, because nobody keeps a sync feature that makes the app feel heavy. In my tests with a 10,500-note library, a fresh iPhone downloaded everything in 30 seconds and Android in 44. A new note written on an iPhone reached the server in about two and a half seconds. A single note can hold up to 1 MB of text, which is longer than most novels.
The trade-offs I chose
Gravity Sync isn't end-to-end encrypted. Notes are encrypted in transit and at rest, and the server can read them, which is what lets it merge by meaning and lets you sign in on a new phone with Google or Apple and get everything back with no recovery key. Losing a phone is the disaster most people actually have, so I chose easy recovery.
It also keeps no version history yet. Conflict copies cover the case where two devices disagree, but you can't scroll back through last month's edits. The revision IDs and hashes are already there, so history can come later without changing the protocol.
Gravity Sync is free during the beta. Turn it on in Settings on any device, sign in with Google, Apple or an email code, and your notes follow you, including between an iPhone and an Android phone. If you find a way to make it lose a note, I want to hear about it at the contact page.