Skip to content

iOS Native App: Offline‐First & Sync

Henry Zhu edited this page Jul 19, 2026 · 1 revision

001 — Multi-Method Sync (iCloud / API self-host / API cloud)

Offline-first. The local JSON file is always the working copy — every tap updates memory + UI + file immediately, with no network in the path. Sync then runs in two lanes:

Lane Carries Transport When
Realtime tick tick done / note only WebSocket wss://…/ws/sync while app is foreground
Full sync everything else + reconciliation HTTP export / import first connect · foreground · pull-to-refresh · periodic

Three methods share this engine; the two API methods are the same code, differing only in endpoint:

Method Transport Endpoint
iCloud file coordination (existing) ubiquity container
API — self-host HTTPS + wss user-entered URL
API — cloud HTTPS + wss https://beaverhabits.com

Flow A — Realtime tick over WebSocket

Each device opens one authenticated WebSocket while foregrounded. A tick applies locally first (memory + file), then goes out on the socket; the server persists it and broadcasts it to the user's other connections, which apply it directly — the tick payload is tiny and self-contained, so no follow-up pull is needed.

sequenceDiagram
    autonumber
    actor U as User (iPhone)
    participant P as iPhone
    participant S as Server /ws/sync
    participant Q as iPad (same user)

    Note over P,Q: both foreground -> each holds one wss connection
    U->>P: toggle(habit, day)  %% or add/edit note
    P->>P: mutate memory + file (instant)
    P->>S: ws send ( type:tick, habit_id, day, done, text, hlc )
    S->>S: persist tick (reuse habit.tick), LWW by hlc
    S-->>Q: ws broadcast ( same tick )
    Q->>Q: apply via HabitMerger LWW -> UI updates live
    Note over S,Q: broadcast skips the origin connection
Loading

Reconnection: on drop / return-to-foreground the client reopens the socket and runs one full sync (Flow B) to catch any ticks missed while disconnected.


Flow B — Full sync (everything else + reconciliation)

Non-tick edits and the drift backstop go through a whole-dict merge round-trip: pull the remote dict, merge with local via HabitMerger, push the merged result back. Runs on first connect, foreground, pull-to-refresh, and periodically.

sequenceDiagram
    autonumber
    participant VM as HabitListViewModel
    participant ST as HabitStore (local file)
    participant SE as SyncEngine
    participant S as Server

    Note over VM: first connect / foreground / pull-to-refresh / periodic
    VM->>ST: load() -> localHabits
    SE->>S: GET /api/v1/habits/export
    S-->>SE: remoteDict (full)
    SE->>SE: merged = HabitMerger.merge(local, remote)
    alt merged != last pushed
        SE->>S: POST /api/v1/habits/import ( merged )
        S-->>SE: 200
    else unchanged
        Note over SE,S: skip POST (hash equal) -> no server write
    end
    SE->>ST: save(merged)
    SE->>VM: publish merged
Loading

Realtime scope. Local edits are instant. Cross-device tick is live only while both apps are foreground (iOS tears down sockets in background). When an app returns to foreground it reconnects + full-syncs. True background delivery (app closed) would need APNs — out of scope; "latest on next open" is acceptable for a habit tracker.

Clone this wiki locally