Skip to content
  • 8 min read
  • Building BrainDump

Designing an offline-first sync engine for BrainDump

How I replaced iCloud sync in my iOS notes app BrainDump with my own offline-first sync on Supabase: versioned compare-and-swap on the server, a 3-way merge on the device, conflict copies instead of lost text, and a randomized simulation that found the bugs.

For years, BrainDump synced your notes with iCloud through Core Data's NSPersistentCloudKitContainer. It's a great default: a few lines of setup and your data shows up on every device.

But as BrainDump grew beyond Apple devices, with a web app alongside iPhone, iPad and Mac, I moved its backend to Supabase (Postgres). That meant the app needed its own sync protocol, one I could reason about, debug and test end to end.

The goal was not "real-time collaboration". It was much more specific, and in some ways harder:

  • One person, many devices. Your phone, your Mac, the web.
  • Fully offline. Read, write, edit and delete without a connection, for as long as you want.
  • Your text is never silently lost. Ever. Even when two devices edited the same note offline.
  • Incremental. Reconnecting after a week shouldn't mean downloading everything again.

I wrote the whole design down before writing any code. This article walks through the main decisions.

The hard problems first

Before choosing an approach, I listed the problems in order of how much they'd shape the architecture. The top ones:

  1. Detecting concurrency without trusting clocks. "Device B edited on top of A's change" and "A and B edited the same version" look identical if you only compare timestamps, and device clocks are wrong more often than you'd think.
  2. How finely to merge text. Notes are HTML from a rich-text editor. Merge granularity decides whether two edits combine, conflict, or overwrite each other.
  3. Never saving a change without queuing it for sync, and never queuing one that wasn't saved.
  4. Retries that are always safe. Lost responses and duplicate requests must not apply a change twice.
  5. Pulling changes without gaps, even while other writes are committing.
  6. Deletes that don't come back from a stale device, and don't destroy a concurrent edit.

Choosing an approach

I compared four options:

Whole-row last-write-winsVersioned compare-and-swapOperation logCRDT
Detects concurrent editsNoYesYesYes
Depends on device clocksYesNoNoNo
Can lose an editYes, silentlyNoDependsNo
Server complexityTrivialSmallLargeOpaque
Needs editor changesNoNoYesYes

Last-write-wins is simple and loses data, so it was out. CRDTs and operation logs are excellent tools for real-time collaboration, but they need the editor to emit fine-grained operations (mine saves whole HTML snapshots) and they grow history that has to be compacted. For one person syncing their own devices, that's a lot of machinery for a problem I didn't have.

I picked the second column: versioned compare-and-swap on the server, plus a 3-way merge on the device.

The server: small, strict and dumb about text

Each user has a counter, rev, that only goes up. Every change the server accepts stamps the row with the next value.

When a device sends a change, it also says which version it was editing: "set the content of note X, based on rev 10". The server accepts it only if the note is still at rev 10. Otherwise it answers conflict and sends back the current row.

That's it. The server never merges text. It decides ordering and acceptance, which keeps it small, deterministic and easy to verify with database tests.

Every change also carries a unique operation ID, and the server remembers applied operations, so a retried request is recognized instead of applied twice.

Pulling is just as simple: "give me everything with a rev greater than my cursor". There's no separate change log, because the rows themselves, plus deleted-row markers called tombstones, are the log. The rev is allocated while holding a lock on the user's row until commit, so a pull can never skip a change that was still being written.

The device: a shadow, an outbox and one merge function

On the device, each note that has unsynced changes keeps three things:

  • its current state, what you see;
  • a shadow, the last version the server confirmed, which your edits are based on;
  • an outbox entry, the pending change to send.

When the server answers conflict, the device runs one pure function:

merge(base: shadow, local: current, remote: serverRow)

…and resubmits the result on top of the new version. The same function runs when a pull brings a remote change for a note you're editing. There is exactly one merge path, which made it possible to test it thoroughly.

The outbox also coalesces changes. Typing for ten minutes doesn't create hundreds of pending updates; it's one change, based on the last confirmed version.

Merging per field, and text per block

merge works field by field. If only one side changed a field, that side wins. If both changed it:

  • Simple values (folder, pinned, color): the later change wins, in the order the server received them, not by device clock.
  • Sets (tags): a 3-way set merge. If one side adds a tag and the other removes a different one, both happen.
  • Note content: a 3-way merge at block level.

Block level means the HTML is split into paragraphs, headings, quotes, code blocks, and each list or checklist item as its own block. Then a classic diff3 runs over the three versions:

  • A block changed on only one side is taken.
  • Two different insertions at the same point are both kept.
  • Two different edits to the same block are a conflict.

In practice, this means editing different paragraphs on two devices merges automatically, and ticking different checklist items on your phone and Mac just works. Character-level merging would combine even more edits, but it would need a different editor. Block level was the right trade-off.

Conflicts become copies, never losses

When a real conflict happens, both of you edited the same paragraph offline, the engine doesn't pick a winner and throw the other version away. The original note takes the server's version, and your version is saved as a conflict copy: a new note titled "(other version)", in the same folder. Both show a banner to compare them, and you decide what to keep.

The copy's ID is derived from the operation and its content, so a crash and retry can't create two copies, and a later, different conflict can't overwrite an earlier one.

Deletes: the edit wins

Deletes were the most subtle part.

  • Moving to Trash is just a folder change, so it syncs like any other edit.
  • Deleting permanently creates a tombstone: the server removes the content right away but keeps a tiny marker row forever, so every device's cursor stays valid.
  • If one device deletes a note while another edits it, the edit wins and the note stays. It's the only option where no text is lost. Restoring a note you deleted is mildly annoying; losing a paragraph you just wrote is not acceptable.

Folders bring their own rules: a note whose folder was deleted elsewhere moves to Uncategorized, and moving two folders inside each other on two offline devices is detected as a cycle and rejected.

Proving it: a randomized simulation

Sync bugs hide in timing: a response lost at the wrong moment, an app killed between two writes. Unit tests and hand-written scenarios cover the cases you think of. I wanted to find the ones I didn't.

So the test suite includes a seeded, randomized simulation. Three to five devices share one fake server. They create, edit and delete notes, add and remove paragraphs, create, move and delete folders, and tag notes. Meanwhile they go offline, lose requests and responses, relaunch, and crash between writes. Then every device reconnects, syncs until there's nothing left, and three invariants are checked:

  • Convergence: every device matches the server, and nothing is left to send.
  • No rejections: device and server never disagree about what an operation means.
  • No lost text: every paragraph any device wrote still exists somewhere, unless a device that could see it deliberately removed it. Each paragraph carries a unique token, so this check is exact.

The simulation found real bugs that no scenario test had caught, including:

  • a change referencing a folder that was deleted locally before it was ever uploaded, which waited forever;
  • folder cycles among folders that had never been uploaded;
  • a crash between the two local writes leaving a record that was saved but never queued;
  • a later conflict overwriting an earlier conflict copy.

After the fixes, two batches of 7,500 runs passed.

Leaving iCloud without losing anything

The last piece was the migration. Two sync systems writing to the same store at once is the riskiest moment of a change like this.

The switch happens per device, after you sign in: the app waits for iCloud to finish syncing, makes sure every note has a unique ID, then turns iCloud off for good on that device and starts account sync on the next launch. Reinstalling the app never re-imports an old iCloud copy.

What I'd tell myself before starting

  • Write the design down first. Listing the hard problems before choosing an approach saved me from building the wrong thing.
  • Don't trust device clocks for correctness. Versions from the server are boring and reliable.
  • Pick the merge granularity your editor can actually support.
  • When in doubt, keep both versions. A duplicate note is a minor inconvenience; lost text is a broken promise.
  • Simulate chaos. Randomized, seeded tests with invariants find the bugs you'd never think to write a test for.

BrainDump is on the App Store. If you're curious about the AI side of the app, read how I built AI into BrainDump, or see the BrainDump case study.

Building an app that needs to work offline and sync reliably? This is the kind of work I do.

  • #iOS
  • #Swift
  • #Sync
  • #Offline-first
  • #Supabase
  • #Postgres
  • #Core Data

Written by Francesco Leoni

Independent iOS & web developer in Bergamo, Italy. I design, build and publish my own apps, and build them for startups and companies.

Coda06 / 06

Let’s buildsomething..

Tell me about your idea and where you are today. A short email is all it takes to get started.

or write to leoni.francesco98@gmail.com