Mobile No. 344

Offline Sync skill for Claude Code

Local-first writes that never vanish or duplicate. Claude designs offline support per entity instead of bolting a cache onto API calls. Writes commit to the local database together with an outbox entry, a single worker pushes them with idempotency keys and backoff, deltas arrive by server cursor with tombstones, and each entity gets an explicit conflict rule — versioned last-writer-wins, field merges, operations or CRDTs — with failed items surfaced, never dropped.

  • nameoffline-sync
  • size189 lines · ≈2,350 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs offline-first mobile data — a local database as the UI's source of truth, a transactional outbox of pending writes with idempotency keys, delta pulls with server cursors and tombstones, conflict resolution (server-versioned last-writer-wins, field merges, operations, CRDTs), optimistic UI with visible pending and failed states, and background sync triggers on iOS and Android. Use when an app must work without connectivity, edits are lost or duplicated on flaky networks, two devices overwrite each other, or when choosing SQLite, Room, SwiftData, WatermelonDB, Drift, or a sync engine.

Try asking

  • Make our field inspection app work offline and sync when signal returns
  • Edits get duplicated when the network drops mid-save — fix the sync
  • Two technicians editing the same job overwrite each other; how should we merge?

Use it when

  • An app must keep working without connectivity
  • Writes are lost or duplicated on flaky networks
  • Multiple devices edit the same records
  • Choosing a local database or sync engine for mobile

Skip it when

How to check the result

Airplane-mode edits survive an app kill and drain on reconnect, toggling the network mid-sync produces no server duplicates, and two-device conflicts resolve exactly as the documented rule says.

The common mistake it prevents

Resolving conflicts by comparing device timestamps, which are routinely wrong, so a phone with a skewed clock silently wins or loses every edit.

~/.claude/skills/offline-sync/SKILL.md

--- name: offline-sync description: Designs offline-first mobile data — a local database as the UI's source of truth, a transactional outbox of pending writes with idempotency… license: MIT ---

Offline-first is done right when the UI reads only from the local database, every user write commits locally together with an outbox entry in one transaction, the sync worker replays the outbox idempotently and pulls deltas by server cursor, conflicts are resolved by an explicit rule per entity, and no write is ever dropped silently.

Decide the level of offline support

LevelBehaviorCost
0. Online-only with cacheShow last-fetched data read-only; block writes offlineLow — HTTP cache or query cache persistence
1. Offline readsLocal DB mirrors server data; writes need networkMedium
2. Offline writes, rare conflictsOutbox + LWW per record; user-owned dataMedium-high
3. Collaborative offline editsField merges, operations, or CRDTs; conflict UIHigh

Pick the lowest level that meets the product need, per entity. A notes app needs level 2–3 for notes and level 1 for the template catalog.

Workflow

- [ ] 1. Classify entities: offline level, ownership, conflict likelihood
- [ ] 2. Choose local storage (and whether a sync engine replaces custom sync)
- [ ] 3. Schema: client IDs, server version, tombstones, local sync status
- [ ] 4. Write path: local write + outbox in one transaction
- [ ] 5. Push: ordered, batched, idempotent, retry with backoff, poison handling
- [ ] 6. Pull: cursor-based deltas including deletes, applied transactionally
- [ ] 7. Conflict rule per entity + optimistic UI states
- [ ] 8. Triggers, auth expiry, logout wipe, migrations
- [ ] 9. Chaos tests: airplane mode, kill mid-sync, two devices, clock skew

Local storage choice

PlatformDefaultAlternatives
iOS nativeSwiftData (iOS 17+) or GRDB (SQLite, precise control)Core Data
Android nativeRoom (SQLite) with Flow queriesSQLDelight
React Native / Expoexpo-sqlite or op-sqlite; WatermelonDB for large reactive datasetsMMKV for small key-value only
FlutterDrift (SQLite, reactive streams)sqflite
Want sync handled for youPowerSync, ElectricSQL, Couchbase Lite, Firestore offline persistenceEvaluate vendor lock-in and conflict model

MongoDB Realm / Atlas Device Sync reached end of life in 2025; plan migrations off it. Key-value stores (AsyncStorage, UserDefaults, SharedPreferences) are not databases: no queries, no transactions — do not build sync on them.

Schema

CREATE TABLE tasks (
  id            TEXT PRIMARY KEY,          -- client-generated UUIDv7/ULID: works offline, sortable
  title         TEXT NOT NULL,
  done          INTEGER NOT NULL DEFAULT 0,
  updated_at    TEXT,                       -- server timestamp, informational
  version       INTEGER,                    -- server-assigned, increments per change; NULL = never synced
  deleted       INTEGER NOT NULL DEFAULT 0, -- tombstone; hard-delete only after server confirms
  sync_status   TEXT NOT NULL DEFAULT 'synced'  -- synced | pending | failed
);

CREATE TABLE outbox (
  seq             INTEGER PRIMARY KEY AUTOINCREMENT,
  entity          TEXT NOT NULL,
  entity_id       TEXT NOT NULL,
  op              TEXT NOT NULL,            -- create | update | delete | domain op e.g. 'complete'
  payload         TEXT NOT NULL,            -- JSON: changed fields only + base_version
  idempotency_key TEXT NOT NULL UNIQUE,
  attempts        INTEGER NOT NULL DEFAULT 0,
  last_error      TEXT,
  created_at      TEXT NOT NULL
);

CREATE TABLE sync_state (scope TEXT PRIMARY KEY, cursor TEXT);

Write path

await db.transaction(async (tx) => {
  await tx.run("UPDATE tasks SET done = 1, sync_status = 'pending' WHERE id = ?", [id]);
  await tx.run(
    "INSERT INTO outbox (entity, entity_id, op, payload, idempotency_key, created_at) VALUES (?,?,?,?,?,?)",
    ["task", id, "update", JSON.stringify({ done: true, base_version: task.version }), uuidv7(), nowIso()],
  );
});
syncScheduler.kick();   // best effort; the outbox survives app death
  • Local write and outbox insert must be one transaction, or a crash between them loses or orphans the change.
  • Send changed fields plus base_version (the version the edit was based on), not the whole record — this enables field-level merges and conflict detection.
  • The UI observes the local DB reactively and shows pending state (subtle icon); it never waits on the network.

Push

  1. Read outbox in seq order, batch (e.g. 50 ops), send with each op’s idempotency key.
  2. Server applies each op idempotently (stores processed keys for at least the max offline window), validates, increments version, returns the canonical record or a conflict.
  3. On success: delete the outbox rows and write the canonical record locally with sync_status = 'synced' — unless newer local ops for that entity are still queued.
  4. Transient failures (network, 5xx, 429): stop the batch, retry with exponential backoff plus jitter (e.g. 1 s, 2 s, 4 s … cap 5 min), preserving order.
  5. Permanent rejection (400 validation, 403): mark the entity failed, keep the op with last_error, surface it to the user (“Couldn’t save — tap to review”). Never drop it silently, and do not let it block unrelated entities forever (move it to a dead-letter state).

Ordering: keep ops for the same entity in order. Coalescing multiple updates to one entity into one op is fine; reordering a delete before a create is not.

Pull

GET /sync/changes?since=opaque-cursor-123&limit=500
→ { "changes": [ { "entity": "task", "id": "...", "version": 18, "deleted": false, "data": {...} } ],
    "next_cursor": "opaque-cursor-456", "has_more": true }
  • The cursor comes from the server (sequence number, LSN, or change-log ID) — never the device clock, which is wrong on many devices.
  • Deletes arrive as tombstones; a server that only returns live rows cannot propagate deletions.
  • Apply each page in one local transaction and save next_cursor in the same transaction.
  • If a pulled record has pending local ops, do not blindly overwrite it: apply the conflict rule (below) or rebase the pending ops on the new server version.
  • Provide a full resync path (cursor reset) for corrupted state or when the server says the cursor is too old (e.g. HTTP 410).
  • Order: push first, then pull, so the pull reflects your own writes.

Conflict resolution

Entity typeRule
User-owned, single editor (settings, drafts)Server-versioned last-writer-wins on the record
Shared records, different fields editedField-level merge: apply fields whose server value still equals the base
Counters, inventory, balancesSend operations (increment by 2), never absolute values; server validates
Collaborative text or listsCRDT (Yjs, Automerge) or server-side operational transform
High-stakes data (orders, payments)Server authoritative; client ops are requests that can be rejected
Conflicts users must decideKeep both versions and show a resolution UI

LWW must compare server versions or server receive order, not device timestamps. For detailed algorithms, merge examples, and server-side conflict detection, read references/conflict-resolution.md when an entity needs anything beyond record-level LWW.

Sync triggers

  • App launch and foreground; after each local write (debounced 1–2 s); connectivity regained (NWPathMonitor on iOS, ConnectivityManager.NetworkCallback on Android, NetInfo in RN, connectivity_plus in Flutter).
  • Background: Android WorkManager with a NetworkType.CONNECTED constraint (unique work, KEEP policy); iOS BGAppRefreshTask/BGProcessingTask — opportunistic, not guaranteed; silent push as a hint (see mobile-push-notifications).
  • Connectivity APIs report a network path, not that your API is reachable. Always handle request failure even when “online”.
  • Run one sync at a time (a mutex or single worker); overlapping syncs duplicate pushes.

Auth, security, lifecycle

  • Expired tokens while offline: queue stays intact; refresh on reconnect; if refresh fails, require sign-in but keep the outbox for the same user.
  • Logout: confirm with the user if the outbox has unsynced changes, then wipe the local DB (or use one DB file per user). Never show user A’s data to user B.
  • Encrypt sensitive local data (SQLCipher, iOS Data Protection classes, Android encrypted storage for keys).
  • Local schema migrations must preserve the outbox. Version outbox payloads so a new app version can still send ops queued by the old one.

Verification

  1. Airplane mode: create, edit, delete; kill the app; relaunch offline — all changes visible and pending. Go online — outbox drains, server matches.
  2. Throttle to a lossy network (Network Link Conditioner on iOS, emulator network settings on Android); toggle connectivity mid-sync. No duplicates on the server (check idempotency keys) and no lost edits.
  3. Two devices edit the same record offline, then sync: the result follows the documented rule for that entity.
  4. Set the device clock a day off: sync still converges.
  5. Force a permanent server rejection: the item shows a failed state with a recovery action, and other entities keep syncing.
  6. Delete on device A, sync, then sync device B: the record disappears on B.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/conflict-resolution.md 118 lines · 5.5 KB
# Conflict resolution for offline sync

Read this when an entity needs more than record-level last-writer-wins: shared records,
counters, collaborative content, or a resolution UI.

## Detecting conflicts on the server

Every op carries `base_version` — the server version the client edited from.

```text
on apply(op):
  current = load(op.entity_id)
  if op.idempotency_key already processed: return stored result
  if current is None and op.op == "create": insert, version = 1
  elif current.version == op.base_version: apply, version += 1          # fast path, no conflict
  else: resolve(current, op)                                             # concurrent edit
  record idempotency_key -> result
```

Return the canonical record (with new version) on success. On an unresolvable conflict,
return `409` with the current server record so the client can rebase or ask the user.

## Strategy 1 — server-versioned last-writer-wins (record level)

Accept the incoming op and overwrite. "Last" means last received by the server, never the
device timestamp. Suitable for single-editor data. Document that concurrent edits from two
devices keep only the later sync.

## Strategy 2 — field-level three-way merge

Clients send changed fields only, plus `base_version`. The server keeps enough history
(the base snapshot or per-field versions) to do a three-way merge:

```text
for field, new_value in op.changes:
  base_value = snapshot(op.base_version)[field]
  if current[field] == base_value:   apply new_value        # nobody else changed it
  elif current[field] == new_value:  no-op                  # same change on both sides
  else:                              conflict on field       # both changed differently
```

Per-field conflicts fall back to a rule (server wins, client wins, or flag for user).
Cheaper variant without snapshots: store `field_versions` (a version per field) and compare
those to `base_field_versions` from the client.

Example: device A changes `title`, device B changes `due_date`, both offline. Record-level
LWW loses one change; field merge keeps both.

## Strategy 3 — operations instead of state

For counters, quantities, balances, membership, and ordered lists, send intent:

| State-based (conflicts) | Operation-based (merges) |
|---|---|
| `{ "stock": 7 }` | `{ "op": "decrement_stock", "by": 2 }` |
| `{ "tags": ["a", "c"] }` | `{ "op": "add_tag", "tag": "c" }`, `{ "op": "remove_tag", "tag": "b" }` |
| `{ "position": 3 }` | `{ "op": "move", "after_id": "x" }` with fractional indexing |

The server validates each op against current state (stock cannot go negative) and may
reject it, which the client shows as a failed item. Ops must be idempotent via their
idempotency key since they are not naturally idempotent.

Fractional indexing for ordering: store a sortable string key between neighbors
(`"a0"`, `"a0V"`, `"a1"`), generated on the client, so concurrent inserts rarely collide;
break ties by ID.

## Strategy 4 — CRDTs

Use when users co-edit rich text, whiteboards, or nested documents offline.

| Library | Fit |
|---|---|
| Yjs | Mature, fast, many editor bindings (ProseMirror, Tiptap, CodeMirror), y-websocket / custom providers |
| Automerge | JSON-like documents, Rust core with JS/Swift/Kotlin bindings, good offline story |

Store the CRDT document (or update log) as an opaque blob per entity, sync updates as
binary deltas, and let the CRDT merge. Costs: document size grows with history (compact or
snapshot periodically), server-side validation is harder (you cannot easily reject one
user's edit), and queries need a derived projection table.

## Strategy 5 — user resolution

When automatic rules would surprise users (two edits to the same note body, conflicting
appointments):

1. Keep both versions: the server version becomes canonical; the local version is stored as
   a conflict copy linked to the record.
2. Show a non-blocking indicator ("2 versions") and a compare screen: keep mine, keep
   theirs, or merge manually.
3. Resolution produces a normal update op with the latest `base_version`.

Never block the whole app on a conflict dialog; resolve per item.

## Deletes versus edits

| Case | Recommended outcome |
|---|---|
| Device A deletes, device B edits offline | Usually keep the delete and drop the edit with a notice; or resurrect if the edit matters more (e.g. documents) — decide per entity and document it |
| Delete then re-create with same client ID | Disallow; client IDs are never reused |
| Tombstone retention | Keep tombstones at least as long as the longest supported offline period (e.g. 30–90 days); clients offline longer must full-resync |

## Client-side rebase

When a pull brings a newer server version for an entity with pending ops:

1. Write the server record as the new base.
2. Re-apply the pending ops locally on top of it (they are field changes or operations).
3. Update the pending ops' `base_version` only if the strategy merges (field/op based);
   with record LWW keep it and let the server detect the conflict.
4. If re-application fails validation locally, mark the entity `failed` for user review.

## Testing conflicts

- Deterministic tests: script two clients against a test server with explicit interleavings
  (A edits, B edits, B syncs, A syncs) and assert the final state per strategy.
- Property-based tests: generate random op sequences for two or three clients, sync in random
  order, assert all replicas converge to the same state (see `property-based-testing`).
- Include clock skew, duplicate delivery of the same op, and out-of-order page pulls.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc