Mobile API Best Practices · Offline-First & Sync
Delta Sync Protocols
Sync only what changed: change tokens, tombstones and resync.
Full re-downloads don't scale. A delta sync endpoint returns changes since a sync token (a server-issued opaque cursor over a monotonic change log or per-row updated_seq). Deletions are sent as tombstones (keep them long enough for slow clients). If a token is too old (log compacted), respond with 410 Gone / resync_required so the client does a full snapshot. Scope changes to what the user may see — permission changes are also "changes".
Sync approaches
| Criterion | Full snapshot | updated_at > T | Change log / sequence token |
|---|---|---|---|
| Bytes per sync | Everything | Changed rows | Changed rows |
| Deletes | Implicit | Need soft deletes | Tombstones in log |
| Clock-skew safe | Yes | No (use server time, ties) | Yes (monotonic seq) |
| Missed-update risk | None | Concurrent commits can be skipped | None if seq assigned at commit |
| Server cost | High | Low | Low (log storage) |
GET /v1/sync?since=st_000123987&limit=500
200 OK
{
"changes": [
{"type": "task", "op": "upsert", "id": "01J9…", "version": 14, "data": {…}},
{"type": "task", "op": "delete", "id": "01J8…", "version": 9}
],
"next_token": "st_000124487",
"has_more": true
}
# Token older than retention:
410 Gone {"type": "https://api.example.com/problems/resync-required"}
@router.get("/v1/sync")
async def sync(since: str | None = None, limit: int = Query(500, le=1000), user=Depends(current_user)):
seq = decode_token(since) if since else 0
if seq and seq < await oldest_retained_seq():
raise ProblemError(410, "resync-required")
rows = await db.fetch(
"SELECT seq, entity_type, entity_id, op, version, data FROM change_log "
"WHERE owner_id = $1 AND seq > $2 ORDER BY seq LIMIT $3", user.id, seq, limit + 1)
page, more = rows[:limit], len(rows) > limit
return {"changes": [to_change(r) for r in page],
"next_token": encode_token(page[-1]["seq"] if page else seq), "has_more": more}
Common pitfalls
- Using client clocks for 'changed since'.
- Purging tombstones before all clients have synced.
- Ignoring permission revocations (client keeps data it should lose).
Key points
- Opaque sync tokens over a monotonic sequence
- Tombstones for deletes
- 410 → full resync
Further reading: Apple: CKSyncEngine
Practice questions
Why is updated_at > last_sync_time risky for delta sync?
Answer
Clock skew and transactions that commit out of timestamp order can make changes invisible to a client that already advanced its timestamp; a commit-ordered sequence avoids that.
What should the server do when a client's sync token is older than the retained change log?
Answer
Return a resync-required signal (e.g. 410 Gone) so the client discards its token and downloads a fresh snapshot.
Open in the app to mark lessons complete, take notes and track your progress.