Skip to main content

Offline-First Strategy

Offline-First Strategy

Project: Drop — Fintech Payment App{{PROJECT_NAME}} Version: 0.1.0{{VERSION}} Date: 2026-02-23{{DATE}} Author: John (AI Director, ALAI){{AUTHOR}} Status: Draft | In Review | Approved Reviewers: Alem Bašić (CEO){{REVIEWERS}}

Document History

Version Date Author Changes
0.1 2026-02-23{{DATE}} John{{AUTHOR}} Initial draft — current state (no offline support) + Phase 2 requirements

1. Offline Capability Requirements

Critical context: Drop is a pass-through payment app (PSD2 PISP model). Drop never holds customer money. All payment transactions require:

  1. Real-time balance verification via AISP (Open Banking)
  2. Live payment initiation via PISP (Open Banking)
  3. BankID authentication for sensitive operations

This fundamentally limits offline capability — payment initiation cannot work offline by design.

Feature Offline Support Priority Notes
SendView moneycached content feedRequiredP1Last 50 items
Create draft (remittance)saved locally)RequiredP1Sync when online
Search (local cache only)PartialP2Degraded — no remote results
User authentication Not supportedrequired Requires live PISP + BankID. Cannot be queued.
QR paymentNot supportedRequires live PISP + real-time merchant verification
View balanceNot supportedAISP reads live balance from bank — no cached balance
View transaction historyPartial (Phase 2)P2Last 50 transactions cached locally
View recipient listPartial (Phase 2)P2Cached recipients for reference
Notification historyPartial (Phase 2)P2Cached notification list
LoginNot supportedBankID requires network
ExchangePush ratesnotifications Partial (Phase 2)N/A P3 LastRequires knownnetwork rateby shown as estimatenature
AppFile navigationuploads SupportedQueue P1P2 AppUpload shell loads withoutwhen network returns
{{FEATURE}}{{Required/Partial/Not required}}{{P1-P3}}{{Notes}}

Phase 1 (current) offlineOffline minimum viable experience:

WhenTODO: Define what the user should see/do when completely offline, the app shows a "Ingen nettverkstilkobling" error banner and prevents all payment operations. Previously loaded screens remain visible but all data shows as stale. No payment data is cached.

Phase 2 offline minimum viableblank experience:

User can viewscreen, cached transactiondata, historyor (lastread-only 50), recipient list, and notification history without network. A banner indicates "Viser lagret data". All payment and balance operations require network.mode.


2. Local Storage Architecture

2.1 Current StateDatabase (PhaseStructured 1)

DataStorageNotes
Auth tokenAsyncStorage (in-memory during session)Module-level let token in lib/api.js
User preferencesNone — fetched from server on each launch
Transaction historyNone — fetched on each page load
RecipientsNone — fetched on send page load

Phase 1 conclusion: Drop has no offline storage beyond the auth token. All data is fetched fresh on each mount.

2.2 Phase 2 — Planned Local StorageData)

Selected database: expo-sqlite{{WatermelonDB | SQLite (SQLite)expo-sqlite) | lightweight,Realm Expo-managed,| no native module ejection required.TinyBase}}

Rationale:

SQLite
provides

TODO: structuredExplain why this DB was chosen (query capability for transaction filtering and recipient lookup. WatermelonDB addscapability, sync complexitysupport, notperformance, neededbundle forsize).

Drop's read-only cache use case.

Schema overview (Phase 2):overview:

CREATE TABLE transactions (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,               -- 'remittance'Example |schema 'qr_payment' statusexpand TEXTper NOT NULL,
  amount REAL NOT NULL,
  currency TEXT NOT NULL DEFAULT 'NOK',
  recipient_name TEXT,
  created_at INTEGER NOT NULL,      -- Unix timestamp
  synced_at INTEGER NOT NULL        -- When cached
);domain

CREATE TABLE recipientsusers (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  countryemail TEXT UNIQUE NOT NULL,
  account_numberavatar_url TEXT NOT NULL,TEXT,
  synced_at INTEGER,
  updated_at INTEGER NOT NULL
);

CREATE TABLE notificationsposts (
  id TEXT PRIMARY KEY,
  typeuser_id TEXT NOTREFERENCES NULL,users(id),
  title TEXT NOT NULL,
  body TEXT,
  status TEXT DEFAULT 'published',
  is_local_draft INTEGER DEFAULT 0,
  synced_at INTEGER,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE TABLE sync_queue (
  id TEXT PRIMARY KEY,
  entity_type TEXT NOT NULL,
  is_readentity_id TEXT NOT NULL,
  operation TEXT NOT NULL, -- 'create' | 'update' | 'delete'
  payload TEXT NOT NULL,   -- JSON
  retry_count INTEGER DEFAULT 0,
  created_at INTEGER NOT NULL,
  synced_at INTEGER NOT NULL
);
-- No sync_queue table — Drop does NOT queue payment operations offline
-- Payments require real-time network by design

TODO: Define full schema for all entities.


2.2 File Storage

TypeLocationMax SizeEviction
Downloaded images{{FileSystem.cacheDirectory}}/images/200 MBLRU on cache full
Downloaded documents{{FileSystem.documentDirectory}}/docs/500 MBManual user delete
Queued upload files{{FileSystem.documentDirectory}}/uploads/1 GBOn successful upload
Temporary files{{FileSystem.cacheDirectory}}/tmp/50 MBOn app start

Library: {{expo-file-system | react-native-fs}}


2.3 Secure Storage

Data Storage Reason
Auth token AsyncStorage (current) → {{expo-secure-storestore}} (Phase 2) Phase 2: encrypted,Encrypted, Keychain/Keystore
Refresh token {{expo-secure-storestore}} (Phase 2) Encrypted
BiometricEncryption keyskey (for local DB) {{expo-secure-storestore}} (Phase 2) Never in plain storage
Non-sensitiveUser prefspreferences AsyncStorage Language setting, theme preferenceNon-sensitive

Rule: PaymentAnything credentialsaccessed without a password must NOT be in secure storage (BankIDbreaks tokens)biometric areauth never stored locally — they are transient session tokens managed by expo-web-browserflow).


3. Sync Protocol Design

Critical constraint: Drop does NOT have a sync queue for payment operations. Payments cannot be created offline and synced later — this would create unacceptable financial risk and violate PSD2 requirements.

Sync scope: Read-only data (transactions, recipients, notifications) — Phase 2 only.

sequenceDiagram
    participant App
    participant LocalDB
    participant SyncQueue
    participant API

    Note over App,API: Online — foreground sync cycle

    App->>LocalDB: Read local data (Phaseimmediate)
    2)App->>SyncQueue: Queue local changes
    App->>API: GETPush: POST /v1/transactions?limit=50sync/push {changes}
    API-->>App: {Server-applied transactions:changes [...]+ }conflicts
    App->>LocalDB: UpsertApply transactionsserver (replace all)changes

    App->>API: Pull: GET /v1/recipientssync/pull?since={timestamp}
    API-->>App: {Remote recipients:changes [...]since }last pull
    App->>LocalDB: UpsertMerge recipientsremote changes

    Note over App,API: Offline scenario

    App->>LocalDB: Read cached transactions
    LocalDB-->>App: Cached data (may be stale)
    App->>App:SyncQueue: ShowQueue "Viserchanges lagret(persisted)
    data"Note bannerover SyncQueue: Waits for connectivity

    Note over App,API: Reconnect

    App-SyncQueue->>API: GETDrain /v1/transactionsqueue (fresh)— push all pending
    API-->>App: LatestConflict dataresolution
    App->>LocalDB: OverwriteMerge cacheresolved state

3.1 Sync Strategy

Approach: Pull-only{{Bidirectional cachedelta refresh (no push, no bidirectional sync)sync}}

Property Value
Protocol REST + {{GraphQL subscriptions / WebSocket for live}}
Push endpointPOST /sync/push
Pull endpoint GET /v1/transactions?limit=50, GET /v1/recipients, GET /v1/notificationssync/pull?since={unix_ms}&entities={list}
Sync triggeridentifier AppPer-entity foreground,updated_at aftertimestamp successful(server payment, pull-to-refreshclock)
CachePull expirydelta 5Only minutesrecords (datachanged oldersince thanlast 5sync min triggers background refresh)cursor
ConflictBatch resolutionsize ServerMax always100 winsrecords per localpush, cache200 isper read-only, overwritten on syncpull

3.2 Conflict Resolution

Not applicable.

Drop's iscannotmodifycacheddata alwaysoverwritescacheon
EntityStrategyRationale
User profileLast Write Wins (server wins)Single-user edit
Post draftsLast Write Wins (client wins on local cachedraft) User read-only.owns Usersdraft
Settings Merge offline.(union)Non-conflicting fields
Counters (likes, views)Server-side CRDTConcurrent increments
{{Entity}}{{LWW / CRDT / Manual / Server datawins}} {{Reason}}
next

Conflict sync.detection: Compare updated_at + server-assigned version counter.

Manual conflict flow (when required):

  1. Server returns 409 Conflict with both versions
  2. App stores both versions in local DB
  3. User presented with diff UI to choose version
  4. Resolved version pushed back to server

3.3 Sync Frequency & Triggers

type=
Trigger Action Conditions
App foreground (from background) Pull sync for all cached entities Network available,available
Mutation (create/update/delete)Immediate pushNetwork available; else queue
AppState change: background → foregroundFull sync> 5 min since last sync
Pull-to-refreshNetwork restored FullDrain datasync refreshqueue NetworkAny availablequeued changes
AfterTimer successful(background transactionfetch) RefreshPull transactions listsync Network{{Every available15 min}}
AppPush launchnotification (authenticated)received FullPull datasync refreshfor affected entity NetworkNotification available
Network restored (from offline)Full data refreshRe-connect detected'data_update'

3.4 Partial Sync / Delta Sync

  • Client stores last_sync_cursor per entity type (epoch milliseconds)
  • Pull requests include since cursor — server returns only changed records
  • Deleted records: server maintains soft-delete with deleted_at for 30 days
  • Client applies deletions, then removes soft-deleted records from local DB

4. Sync Queue Management

NoQueue sync queue for payments.storage: DropSQLite explicitlysync_queue doestable NOT(survives queueapp payment operations.restart)

Rationale:Queue item schema:

    interface SyncQueueItem {
      id: string;             // UUID
      entityType: string;     // 'post' | 'user' | etc.
      entityId: string;
      operation: 'create' | 'update' | 'delete';
      payload: object;        // Full entity data
      retryCount: number;
      maxRetries: number;     // 5
      createdAt: number;      // Unix ms
    }
    

    Drain strategy:

    1. PSD2On PISPnetwork requirements:restore: paymentdrain must be authorized and initiatedqueue in real-timeFIFO order
    2. Batch up to 50 items per push request
    3. On error: retry with userexponential presentbackoff (1s, 2s, 4s, 8s, 16s)
    4. BankIDAfter consentmaxRetries: ismove single-useto anddead time-limitedletter queue, cannotnotify be storeduser
    5. Financial
    risk:

    TODO: queuingDefine paymentsuser createsnotification potentialUX for duplicatesync or delayed transactions

  • Regulatory: Finanstilsynet requires real-time payment authorization

If user tries to pay while offline: Show error: "Du trenger nettverkstilkobling for å sende penger eller betale med QR."failures.


5. Network State Detection & Handling

Library: {{@react-native-community/netinfonetinfo}} (Phase 2) or expo-network (Phase 1 alternative)

// Phase 2 — networkNetwork state hook
import NetInfo from '@react-native-community/netinfo';
export function useNetworkState() {
  const [isOnline, setIsOnline] = useState(true);
  const [connectionType, setConnectionType] = useState<string>('unknown');

  useEffect(() => {
    return NetInfo.addEventListener((state) => {
      setIsOnline(state.isConnected && state.isInternetReachable);
      setConnectionType(state.type);
    });
  }, []);

  return { isOnlineisOnline, connectionType };
}

UI behavior per state:

foråsende
State UI Response
Offline Banner: "IngenYou're nettverkstilkoblingofflinebetalingershowing er ikke tilgjengelig"
Offline (with cache)Banner: "Viser lagretcached data" (Phase 2 only)
Reconnected Banner: "TilkobletBack onlineoppdaterer.syncing..." (auto-dismiss 3s)
PaymentSlow attempted offlineconnection ErrorNo modal:extra "DuUI trenger(handle nettverkstilkoblingtransparently)
Sync penger"in progressSubtle indicator (not blocking)

Current Phase 1 behavior: API calls fail with network error → fetch() throws → .catch() shows error state. No proactive network detection.


6. Data Flow: Online vs Offline

flowchart TD
    UserAction["User Action"] --> CheckFeature{Payment or\nview feature?}

    CheckFeature -->|"Payment (Send/QR)"| CheckNetwork{Network\nAvailable?}
    CheckFeature -->|"View data"| CheckNetworkView{Network\nAvailable?}

    CheckNetwork -->|Yes| DirectAPI["Yes"|Send InitiatePayment["Initiate payment viato API\n(BankIDndirectly"]
    +DirectAPI PISP)-->|Success| UpdateLocal["Update local DB"]
    DirectAPI -->|Error| QueueAction["Queue action\n+ optimistic update"]

    CheckNetwork -->|"No"|No| ShowError["Error:QueueAction
    Ingen tilkobling\nBetalinger krever nettverk"]

    CheckNetworkViewQueueAction -->|"Yes"| FetchFresh["FetchUpdateLocal
    fresh data from API\nUpdate local cache (Phase 2)"]
    CheckNetworkViewUpdateLocal -->| UpdateUI["No"|Update ShowCached["Show cached data\UI\n(Phase 2) or empty state (Phase 1)"]

    InitiatePayment -->|"Success"| ShowConfirmation["Show payment confirmation"]
    InitiatePayment -->|"Failure"| ShowPaymentError["Show payment error\n(retry option)optimistic)"]

    style ShowErrorUpdateUI fill:#f8d7da#d4edda
    style ShowPaymentError fill:#f8d7da
    style ShowCachedQueueAction fill:#fff3cd
    style ShowConfirmation fill:#d4edda

7. Testing Strategy for Offline Scenarios

(networkfailure)
Test Type Scope Tool
Unit APISync errorqueue handlingoperations Jest
UnitConflict resolution logic Jest
Integration CacheDB readread/write whenwith offlinemock network Jest + mockin-memory SQLite (Phase 2)
ManualAirplane mode → attempt payment → verify error messageiOS/Android Simulator
ManualAirplane mode → view cached history → verify banner (Phase 2)iOS/Android SimulatorDB
E2E Full offline → reconnect flow (Phase 2) Detox / Maestro
ManualNetwork conditions simulatorNetwork Link Conditioner (iOS), tc (Android emulator)

ManualE2E offline test scenarios:scenario:

  1. Open app online — verify data loads
  2. Enable airplane mode on device
  3. AttemptPerform tocreate/update/delete send money → verify error message appears (not crash)actions
  4. AttemptVerify QRoptimistic paymentUI → verify error message appearsupdates
  5. NavigateVerify toactions transaction history → verify error or empty statequeued (Phaseinspect 1)DB)
  6. Disable airplane mode
  7. Verify verifysync appqueue refreshesdrains
  8. Verify server data automaticallymatches local state

8. Storage Limits & Data Eviction Policy

Phase 1: No local storage to manage.

Phase 2 targets:

with Userif
Storage Type Soft LimitHard Limit Eviction Strategy
SQLite cache (transactions)DB 550 MB (50 transactions max) Always200 overwriteMB Evict latestrecords fromolder serverthan 30 days
SQLiteImage cache (recipients) 1150 MB Always300 overwriteMBLRU eviction
SQLiteDocument cache (notifications) 2200 MB Always500 overwrite
Auth token (AsyncStorage)MB Negligible Onprompt logoutto / token expiryclear
Total app storage < 30500 MB Alert1 userGB Warn user, offer cleanup

Low storage alert: When device storage < 200500 MB free, reduce cache limits by 50%.

User-initiated cleanup: Settings → Storage → Clear Cache option.


9. Error Handling & User Feedback

Error User Feedback Recovery Action
NetworkSync failurepush on paymentfailed Toast: "DuCouldn't trengersync nettverkstilkobling forwill å sende penger eller betale med QR"retry" RetryAuto-retry button,with check connectionbackoff
NetworkConflict failure on data loaddetected EmptyModal: state"Update withconflict retry buttonplease resolve" Pull-to-refreshManual resolution flow
NetworkQueue failureoverflow on(>500 loginitems) "SjekkWarning nettverkstilkoblingen din"banner RetryPartial loginpush, user notified
StaleLocal cacheDB (Phase 2)corruption Alert: "ViserStorage lagreterror data fraplease {time}"reinstall" Auto-refreshOffer onfresh reconnectinstall
SyncStorage failurelimit (Phase 2)reached SilentAlert with appcleanup shows cached dataCTA Auto-retryUser onclears next foregroundcache

Approval

Role Name Date Signature
Author John (AI Director) 2026-02-23
Mobile Lead
Backend Lead
Product Owner