How Local-First Sync Handles Offline Changes
A practical look at durable outboxes, server sequencing, retries, reconnect catch-up, and conflict handling in a local-first task manager.
TL;DR: An offline database isn’t local-first sync. Done Bear writes the change locally, records it in a durable outbox, gives every mutation a stable transaction ID, and lets the server assign the global sequence. WebSockets make updates fast. HTTP catch-up and full bootstrap make recovery correct. When two edits touch the same field, the server wins.
An offline task manager has two jobs that pull against each other. It has to accept a change with no network at all. It also has to reconcile that change with edits from other devices, without losing work or applying the same mutation twice.
Saving a task to a local database does the first half. The hard part starts when the network comes back.
Done Bear runs a server-sequenced sync loop. Each client keeps a working copy and a durable queue of local changes. The server accepts mutations, assigns an ordered action number, and publishes those actions to authorised clients subscribed to the affected workspace. A client can reconnect over a WebSocket, recover missed actions over HTTP, or rebuild from a fresh snapshot when its cursor is too old.
What follows is the design, with the trade-offs and the failure modes we test for. Conflicts don’t disappear here. They become explicit and recoverable.
Why is a local database not enough?
Edit a task while a remote update arrives from another device. If the visible local edit and the record saying “this still needs to be sent” are written separately, the app can land in an impossible state. The screen shows the new title and there’s no durable outbox entry. Restart, and that edit has no path to the server.
So the client serialises the local model change and the durable outbox enqueue. The interface updates optimistically, and rolls that update back if it can’t persist the outbox intent.
In the web client the working copy and sync state live in IndexedDB. On iOS they live in SQLite. Different storage, same rule: persist the local result and the mutation intent before you depend on the network.
The loop:
Local database
|
| optimistic write + durable outbox record
v
POST /sync/mutate
| client ID + stable transaction ID
v
Server applies mutation
|
| append ordered sync action
v
WebSocket publish + HTTP delta history
|
| rebase action + confirm local transaction
v
Local database
That durable boundary matters more than how fast the sync icon looks. It’s what lets a client crash, reopen, and keep sending the same intent.
Why does the server assign the sequence?
Device clocks aren’t an ordering system. A laptop wakes with a stale clock. A phone sits offline for hours. Two clients make valid changes at nearly the same moment and disagree about which came first.
So the server owns the total order. When it accepts a mutation it appends an action to a globally ordered log. The action ID is the cursor a client uses to ask what happened after the last action it applied. Authorisation still bounds that: a client receives only the slice belonging to its authorised sync groups.
The client sends two identities with a mutation: a client ID for the installation, and a transaction ID for this specific local intent. The pair is unique in the server database. If a response goes missing and the client sends the mutation again, the server recognises the duplicate instead of writing twice. That’s why retrying a timed-out request is safe.
Server sequencing claims less than conflict-free collaboration does. It gives authorised participants a consistent order for the history they share. The rebase rules still decide what each action means while local work is pending.
What happens to an offline change?
An outbox item moves through a small state machine:
queued -> sent -> awaiting server action -> removed
^ |
| | transport failure or timeout
+---------+
queued -> explicit server rejection -> failed + rollback
Offline, the item stays queued. When connectivity returns, the client sends it with the same transaction identity. Transport failures use capped exponential backoff and put the item back in the queue.
A successful HTTP response doesn’t always mean the local transaction can go. The matching ordered server action might still be travelling through the sync stream. The client waits until its cursor covers that action, then removes the outbox item.
Without that wait, a mutation could be acknowledged by the write endpoint, removed locally, then missed by a disconnected sync stream. Waiting for the ordered action ties acknowledgement to replicated state.
Explicit rejection is a different thing from a transport failure. A rejected mutation isn’t retried forever. The client marks it failed and reverses the optimistic change. Tests cover a later local edit to the same record too, so rolling back an older rejection doesn’t erase newer work.
How does reconnect avoid missing live updates?
A normal reconnect hides a race.
Fetch missed actions over HTTP, open the WebSocket only after the fetch finishes, and an action created between those two steps falls through the gap. Reverse the order without buffering and a live action can land before an older catch-up page.
Done Bear opens the live subscription from the current cursor while catch-up is still running. Incoming packets wait in a buffer. The client applies the paginated HTTP history first, then drains the buffer in order and resumes streaming.
Reconnect
1. Open WebSocket from the current cursor
2. Buffer incoming live packets
3. Fetch paginated HTTP deltas
4. Apply the catch-up history
5. Drain buffered packets
6. Continue live streaming
The WebSocket is an acceleration path. Correctness lives elsewhere: if the socket drops, the ordered HTTP history fills the gap. A green “connected” dot is useful feedback, but the cursor and the recovery path are what protect the data.
How are concurrent edits resolved?
Not with a promise that there are no conflicts.
Every incoming server action is rebased against local outbox work. If the action is the echo of the local transaction, it confirms that transaction. If remote and local updates touch different fields, both survive. One device changes a task title while another changes its start date.
When both updates touch the same field, the live Done Bear pipeline resolves server-wins. The server-ordered value becomes the shared value, and the client rebases remaining local work on top of it.
Predictable, and still a trade-off. A CRDT could offer different merge semantics, at the cost of more complex data types and more complex user-facing outcomes. Done Bear uses a conventional ordered action log instead, and states the overlapping-field rule plainly.
Serialisation matters here as well. Rebase operations run through a single state queue, so a local optimistic edit can’t interleave halfway through a remote action being applied. The storage boundary and the ordering boundary work together.
What happens when the cursor is too old?
Delta logs can’t grow forever. A device offline for longer than the retained history will ask for actions the server no longer has.
The worst response is pretending that device is current. The protocol treats a stale cursor as an explicit recovery condition and runs a full bootstrap. The client replaces its synchronised base with a fresh server snapshot, then reconciles whatever durable local outbox work it still holds.
The recovery hierarchy is short. The WebSocket handles low-latency delivery, paginated HTTP deltas fetch missed actions, and a full bootstrap takes over when the delta cursor is no longer recoverable.
Every level has a clear trigger. Recovery is part of the protocol, not a catch block wrapped around the happy path.
Which failures does the sync suite test?
The resilience tests say more than an architecture diagram, because they name the outcomes the product has to preserve.
Send a mutation offline and it stays durable, then retries once connectivity returns. Send the same client transaction twice and it’s applied once. Get an explicit rejection and the optimistic change rolls back, and if a later local edit already touched that record, the rollback leaves the newer edit alone.
Feed the client a malformed delta and the cursor doesn’t advance past data it failed to apply. Reconnect in the middle of live traffic and HTTP catch-up completes before the buffered live actions drain. Hand it a stale cursor and it runs a full bootstrap.
No latency chart here. The repository has recovery and contract tests, not a benchmark suite built for public comparison. A made-up speed number would make the architecture less credible, not more.
What does this architecture not promise?
Precise boundaries make infrastructure easier to trust.
Done Bear’s sync design doesn’t promise CRDT semantics, and it doesn’t promise that concurrent edits can never conflict. It doesn’t promise that WebSocket delivery is always available, or that a successful local write has already reached another device. It doesn’t promise a specific replication latency on every network.
It does promise a durable local intent, a stable mutation identity, a globally ordered action log with authorisation-scoped delivery, an explicit overlapping-field rule, and recovery paths for missed or expired history.
Narrower claims. Also testable ones.
What are the reusable lessons?
None of this is specific to task managers. Serialise the optimistic update and the durable outbox enqueue, and roll back if the enqueue fails. Give every mutation a stable identity before its first network attempt, and let one authority assign the shared order.
Treat live sockets as delivery acceleration rather than the only recovery path, and buffer live traffic while catch-up closes historical gaps. State conflict rules in field-level terms. Make stale history a first-class protocol response. Then test the parts nobody demos: crashes, duplicates, rejection, malformed data, and reconnect races.
For users, all of it should disappear. Inbox opens on a plane. An edit survives a restart. Under the stated rebase rules, a shared task converges once two devices reconnect.
Turns out “offline-first” isn’t the local database. It’s everything after the tutorial ends.
Read the shorter explanation of why Done Bear is local-first, see the product’s local-first behaviour, or explore the clients on the download page. Done Bear is free to start.
Get new posts by email
New writing on GTD, local-first software, and building a calm task manager, sent when it is published. Nothing else, and you can leave any time.