# How local-first sync handles offline changes

*Updated 2026-09-22 · Published 2026-08-16 · Matthew Blode*

How Done Bear syncs offline changes: durable outboxes, server sequencing, safe retries, reconnect catch-up, and field-level conflict rules in a local-first app.

---

> **TL;DR:** Done Bear writes an offline change to the local database and records it in a durable outbox in the same step. Every mutation carries a stable transaction ID, and the server assigns the global sequence. WebSockets make updates fast. HTTP catch-up and a 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.

Conflicts still happen. The design makes them explicit and recoverable, and the tests below name the failures it has to survive.

## A local database is only half the job

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:

```text
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.

## The server assigns 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.

## The path of an offline change

An outbox item moves through a small state machine:

```text
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.

## Reconnecting without 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.

```text
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 concurrent edits resolve

Done Bear does not promise that concurrent edits never conflict. 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.

The rule is predictable, and it is 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.

## A stale cursor triggers a full bootstrap

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, so recovery is part of the protocol itself and never a catch block wrapped around the happy path.

## The failures the sync suite tests

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.

This post has no latency chart. The repository has recovery and contract tests and no benchmark suite built for public comparison, and a made-up speed number would only make the architecture less credible.

## What this architecture does not promise

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.

Those claims are narrower, and each one is testable.

## Lessons that apply beyond 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, keep a separate 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.

The person using the app sees none of it. Inbox keeps working on a plane. An edit survives a restart. Under the stated rebase rules, a shared task converges once two devices reconnect.

The local database is the part every tutorial covers. Offline-first is everything after it.

The shorter explanation is [why Done Bear is local-first](/blog/local-first-task-manager). The product’s [local-first behaviour](/local-first) and the clients on the [download page](/download) show it in use, and the [task manager comparisons](/compare) set it against cloud-first apps. Done Bear is [free to start](/pricing).

---

Every Done Bear page is listed at https://donebear.com/llms.txt. Any page has a markdown copy at its URL plus `.md`. Start from the map rather than guessing a path.
