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.
- name
offline-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
- You only need cached API responses in a web app — use state-management
- The question is retry and idempotency design for server APIs — use idempotency-retries
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.
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
| Level | Behavior | Cost |
|---|---|---|
| 0. Online-only with cache | Show last-fetched data read-only; block writes offline | Low — HTTP cache or query cache persistence |
| 1. Offline reads | Local DB mirrors server data; writes need network | Medium |
| 2. Offline writes, rare conflicts | Outbox + LWW per record; user-owned data | Medium-high |
| 3. Collaborative offline edits | Field merges, operations, or CRDTs; conflict UI | High |
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
| Platform | Default | Alternatives |
|---|---|---|
| iOS native | SwiftData (iOS 17+) or GRDB (SQLite, precise control) | Core Data |
| Android native | Room (SQLite) with Flow queries | SQLDelight |
| React Native / Expo | expo-sqlite or op-sqlite; WatermelonDB for large reactive datasets | MMKV for small key-value only |
| Flutter | Drift (SQLite, reactive streams) | sqflite |
| Want sync handled for you | PowerSync, ElectricSQL, Couchbase Lite, Firestore offline persistence | Evaluate 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
pendingstate (subtle icon); it never waits on the network.
Push
- Read outbox in
seqorder, batch (e.g. 50 ops), send with each op’s idempotency key. - 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. - 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. - 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.
- Permanent rejection (400 validation, 403): mark the entity
failed, keep the op withlast_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_cursorin 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 type | Rule |
|---|---|
| User-owned, single editor (settings, drafts) | Server-versioned last-writer-wins on the record |
| Shared records, different fields edited | Field-level merge: apply fields whose server value still equals the base |
| Counters, inventory, balances | Send operations (increment by 2), never absolute values; server validates |
| Collaborative text or lists | CRDT (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 decide | Keep 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 (
NWPathMonitoron iOS,ConnectivityManager.NetworkCallbackon Android, NetInfo in RN,connectivity_plusin Flutter). - Background: Android WorkManager with a
NetworkType.CONNECTEDconstraint (unique work,KEEPpolicy); iOSBGAppRefreshTask/BGProcessingTask— opportunistic, not guaranteed; silent push as a hint (seemobile-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
- Airplane mode: create, edit, delete; kill the app; relaunch offline — all changes visible and pending. Go online — outbox drains, server matches.
- 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.
- Two devices edit the same record offline, then sync: the result follows the documented rule for that entity.
- Set the device clock a day off: sync still converges.
- Force a permanent server rejection: the item shows a failed state with a recovery action, and other entities keep syncing.
- 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
# 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.