Skip to content

Networking Foundation — Phased Implementation Plan

Status: Archived Last updated: 2026-06-27 Owner: TwoMore mobile team

This document records the phased migration toward stale-while-revalidate networking. Treat the phase history as design context, not as proof that the original realtime transport still exists.

It is the architectural answer to: "How do we handle multiple data streams at scale, with cache as the source of truth and need-based refresh, woven through every feature?"


Architectural model

Six layers, strictly bottom-up. Each layer builds on the previous; bugs at lower layers manifest as inexplicable behavior at upper layers.

┌─────────────────────────────────────────────────────────┐
│ L6  Per-feature freshness contract                      │  policy
│       @freshness / @refetchOnFocus / @realtime / @offline│
├─────────────────────────────────────────────────────────┤
│ L5  Sync UI                                              │  feedback
│       <SyncStatusBadge>, <NewDataPill>, <OfflineToast>  │
├─────────────────────────────────────────────────────────┤
│ L4  Freshness triggers (foreground, polling, push)       │  refresh
├─────────────────────────────────────────────────────────┤
│ L3  Mutation queue (offline-first, persisted)            │  writes
├─────────────────────────────────────────────────────────┤
│ L2  Persisted query cache (TanStack on MMKV)             │  reads
├─────────────────────────────────────────────────────────┤
│ L1  Storage substrate (MMKV — synchronous, ~30× faster)  │  bytes
└─────────────────────────────────────────────────────────┘

Phase sequencing

Strict sequential. Each phase has a gate that must pass before the next begins.

PhaseLayerWhat shipsEffortBake gate before next
14aL1MMKV substrate + Zustand migration2dshipped (5558129 + 5de0961) — bake running
14bL2TanStack Query cache persisted to MMKV1.5dshipped (1f1199a) — bake running
14cL4AppState-aware realtime + backpressure1dRETIRED 2026-06-01 — persistent realtime hooks/socket registry removed; current L4 is focused refetch + polling + push
14dL3Offline mutation queue2.5dDEFERRED — needs server-side idempotency_key support; client-only queue would risk duplicate writes
14eL5Sync UI indicators1.5dshipped partial (d2f5f12) — useSyncStatus + SyncStatusBadge. Queued-mutation count + offline toast wait on 14d
14fL6Per-feature contract audit + ESLint rule3d (incremental)DEFERRED — ESLint rule already enforces (P3). 61 ratcheted hooks need @freshness JSDoc backfilled (later)

Total: ~12 working days + ~6 weeks of bake time.

The bake gates are non-negotiable — this work touches every screen and every mutation. Skipping bakes is how data-loss bugs reach production.


Phase 14a — MMKV storage substrate

Goal

Replace AsyncStorage with react-native-mmkv as the substrate for every Zustand persist store. Synchronous reads eliminate the entire class of "AsyncStorage hydration races" we patched with _hasHydrated gates in Phase 13a.

Why first

Every layer above persists data. If MMKV is the substrate, L2/L3/L4/L5 all benefit from synchronous I/O. If we ship L2 on AsyncStorage and migrate to MMKV later, we re-do the persister wiring.

Prerequisites

  • Phase 13a complete (every persist store has partialize + _hasHydrated). ✓
  • Confirm react-native-mmkv v3.x compatible with Expo SDK 55 + new architecture (Fabric).

Scope

New files:

  • packages/app/src/lib/mmkv.ts — single MMKV instance, scoped Zustand-compatible storage adapter.
  • packages/app/src/lib/storage-migration.ts — runs once on app boot; reads AsyncStorage keys, writes to MMKV, marks migrated.

Modified files (14 persist stores):

  • Each store's storage: createJSONStorage(() => AsyncStorage)storage: createJSONStorage(() => mmkvStorage).

New tests:

  • mmkv.test.ts — adapter API correctness.
  • storage-migration.test.ts — read AsyncStorage → write MMKV → idempotency check.

Migration design

Migration runs once on first boot with the new code:

ts
async function migrateAsyncStorageToMMKV(): Promise<void> {
  if (mmkv.getBoolean('storage:migrated:v1')) return;

  const keys = [
    'twomore-theme-store',
    'twomore-auth-store',
    'twomore-checkin-streak',
    // ... all 14 keys
  ];

  for (const key of keys) {
    try {
      const value = await AsyncStorage.getItem(key);
      if (value !== null) mmkv.set(key, value);
    } catch (err) {
      captureCriticalError(err, { context: 'mmkv-migration', key });
      // Continue — partial migration is better than blocking boot
    }
  }

  mmkv.set('storage:migrated:v1', true);
  // Note: we DO NOT delete AsyncStorage values yet. One release later (14a.1)
  // we'll add a cleanup pass once migration is proven stable in production.
}

Risk register

RiskSeverityMitigation
Migration loses user dataHIGHIdempotent + AsyncStorage values not deleted for 1 release; if MMKV write fails, fall back to AsyncStorage for that store
MMKV native module fails to link on iOS or AndroidHIGHTest prebuild + EAS build BEFORE preview channel; rollback path is to revert the storage param
New architecture (Fabric) incompatibilityMEDIUMVerify v3.x supports Fabric (check release notes); v2.x has known issues
Migration runs on every boot if marker write failsLOWMarker is the last write; if it fails, retry on next boot is acceptable

Validation

  • yarn check passes
  • Unit tests for adapter + migration
  • iOS + Android EAS preview build links MMKV native module successfully
  • Manual cold-start test on a build with pre-existing AsyncStorage data — verify all stores hydrate
  • Sentry breadcrumb logs migration success/failure with key counts

Rollback

Single revert per store: change mmkvStorage back to AsyncStorage. Stores read from AsyncStorage again. Data is still in MMKV but no consumer reads it.

Bake gate before 14b

  • 7 days in preview channel
  • Zero Sentry reports of mmkv-migration errors
  • Zero Sentry reports of "store data lost" symptoms
  • Manual smoke test: cold start, verify theme/locale/wizard drafts/streaks all persisted

Phase 14b — Persisted TanStack Query cache

Goal

The TanStack Query cache survives app restarts. Cold start renders from disk → background-revalidates stale entries. The user opens the app and sees the home screen instantly populated with last-seen data.

Why next

This is the single biggest perceived-perf win for users. Once L1 is solid, persisting the cache is straightforward.

Prerequisites

  • 14a shipped + baked
  • Verify TanStack Query v5 cache shape is JSON-serializable (no Maps, Sets, Dates without custom hydrators)

Scope

New files:

  • packages/app/src/presentation/providers/query-persister.ts — MMKV-backed Persister for TanStack.
  • packages/app/src/presentation/providers/query-persister.test.ts

Modified files:

  • apps/mobile/app/_layout.tsx — wire persistQueryClient({ queryClient, persister, maxAge: 24h, buster: APP_VERSION }).
  • apps/web/app/providers.tsx — same for web (use IDB persister or skip on web).

Design

ts
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { mmkv } from '@/lib/mmkv';

const persister = createSyncStoragePersister({
  storage: {
    getItem: (key) => mmkv.getString(key) ?? null,
    setItem: (key, value) => mmkv.set(key, value),
    removeItem: (key) => mmkv.delete(key),
  },
  key: 'twomore-query-cache',
});

persistQueryClient({
  queryClient,
  persister,
  maxAge: 1000 * 60 * 60 * 24, // 24h
  buster: APP_VERSION, // every app version invalidates the cache
  dehydrateOptions: {
    shouldDehydrateMutation: () => false, // mutations live in L3 queue
  },
});

Risk register

RiskSeverityMitigation
Stale cached data shown to user (e.g., yesterday's RSVP count)MEDIUM24h maxAge cap; each query's staleTime triggers refetch if stale; buster: APP_VERSION invalidates on every release
Cache disk size grows unboundedMEDIUMTanStack gcTime defaults to 5min for unobserved queries — they're GC'd before persistence anyway
Sensitive data persistedHIGHAudit query results for PII before this ships; explicitly exclude auth keys via dehydrateOptions.shouldDehydrateQuery

Validation

  • E2E: cold start with seeded cache → home renders from cache → network fetches in background → second cold start renders from updated cache
  • Cache disk size monitored (target <5MB sustained)
  • Sentry breadcrumb: cache hit ratio per cold start

Rollback

Remove persistQueryClient call. On next start, cache is empty; queries fetch fresh.

Bake gate before 14c

  • 7 days in preview
  • Cache hit ratio > 50% on cold start (most users see cached data)
  • No reports of stale data shown after a server-side mutation

Phase 14c — Freshness triggers after realtime retirement

Goal

Keep visible data fresh without a persistent Supabase Realtime socket. The current model uses focused foreground refresh, bounded polling on volatile surfaces, push notification catch-up, refetchOnWindowFocus, and explicit staleTime contracts.

Why third

Independent of L2 in code, but logically belongs in the read-side freshness layer. After L2 ships, the app can choose freshness per surface instead of keeping one app-wide WebSocket strategy alive.

Prerequisites

  • 14a + 14b shipped
  • Query hooks declare freshness expectations through the enforced @freshness JSDoc contract

Scope

  • No persistent Supabase Realtime channels are mounted in the app.
  • DM thread and inbox data use bounded native polling.
  • Live session screens use focus-gated manual invalidation and stop polling once terminal.
  • Push notifications and foreground catch-up close missed-event gaps.
  • The realtime ESLint/ArchUnitTS guards remain as regression guards if a future hook is intentionally reintroduced.

Design

Foreground catch-up and polling posture:

ts
// Representative pattern only.
// Volatile screens use focus/foreground-gated invalidation or bounded refetchInterval.
useQuery({
  queryKey,
  queryFn,
  staleTime,
  refetchOnWindowFocus: true,
  refetchInterval: isFocused && isVolatile ? intervalMs : false,
});

Risk register

RiskSeverityMitigation
Poll interval too aggressive on a volatile screenMEDIUMScope polling to focused surfaces and stop when terminal
Missed critical server-side event while app is backgroundedMEDIUMPush notification catch-up + foreground refetch + user-visible stale/loading indicators
Future realtime reintroduction bypasses lifecycle/focus controlsHIGHKeep realtime-specific lint/fitness guards and require deliberate hook ownership

Validation

  • Manual: open a live session, background app, mutate from another client, foreground — visible data catches up.
  • Manual: open DM inbox/thread, observe bounded polling and no persistent realtime socket.
  • Metrics: Sentry/network traces should not show a global Supabase Realtime WebSocket lifecycle.

Bake gate before 14d

  • 5 days in preview
  • No reports of stale volatile data after foreground
  • No sustained battery/network regression from bounded polling

Phase 14d — Offline mutation queue

Goal

Every mutation is optimistic + queueable. When offline, the optimistic patch holds; the mutation goes into a persisted queue. On reconnect, the queue replays in order. If a replay fails (server-side conflict), surface the conflict to the user with a clear path forward.

Why fourth

Write-side foundation. Depends on L1 (queue persistence). Logically follows L2 (now reads work offline; this makes writes work offline too).

Prerequisites

  • 14a (MMKV needed for queue persistence)
  • 14b stable; freshness-trigger posture validated on volatile screens
  • Audit: every existing mutation hook supports optimistic updates (most don't yet — they'd need to be migrated to use applyOptimisticPatches)

Scope

New files:

  • packages/app/src/presentation/cache/mutation-queue.ts — queue persisted to MMKV; replays on reconnect.
  • packages/ui/src/sync-conflict-toast.tsx — surfaces replay failures.

Modified files:

  • packages/app/src/presentation/hooks/create-mutation-hook.ts — every mutation gets mutationKey for queue identification.
  • 46 existing mutation hooks — add mutationKey.

Design considerations

  • Idempotency keys: every mutation that hits the server passes a client-generated UUID as idempotency_key. Server uses it to dedupe replays.
  • Conflict resolution: when a queued mutation's replay fails (e.g., target row no longer exists), show toast "Couldn't sync your change" with [Retry / Discard] actions.
  • Queue ordering: replays in mutation-order to preserve causality. If a replay fails, downstream queued mutations are blocked until resolved.
  • Stale drop: queued mutations older than 24h are dropped (with user notification: "We couldn't sync your changes from [time]").

Risk register

RiskSeverityMitigation
Queued mutation conflicts with server stateHIGHIdempotency keys + explicit conflict UI
Queue grows unboundedMEDIUM24h drop window; gcTime for completed mutations
Server-side breaking change invalidates queued mutationsHIGHbuster: SCHEMA_VERSION invalidates queue on schema bumps
User performs same action twice (online + retry)HIGHIdempotency keys de-dupe at server

Validation

  • E2E: airplane mode → 5 mutations → reconnect → all replay → verify server state
  • E2E: airplane mode → mutation → server-side change conflicts → user sees toast → user retries → success
  • Sentry: every replay failure logs with conflict context

Bake gate before 14e

  • 14 days in preview (longer because conflict scenarios surface over time)
  • Conflict toast appears at acceptable rate (<1% of replays)
  • Zero "lost mutation" reports

Phase 14e — Sync UI indicators

Goal

Users understand cache/network state without thinking about it. Three primitives:

  1. Refreshing — small spinner near header right (when isFetching > 0 for any visible query)
  2. New data available — pill at top "10개의 새 알림" with tap-to-refresh affordance (when push or foreground catch-up discovers unseen data)
  3. Offline — toast "오프라인 상태입니다 — 변경사항이 동기화되지 않았어요" when offline AND queue has pending mutations

Why fifth

UI surface only. Depends on L2 (refresh indicators reflect actual cache state) AND L3 (offline indicators reflect queued mutation count). Can't ship without both.

Prerequisites

  • 14b + 14d shipped

Scope

New files:

  • packages/ui/src/sync-status-badge.tsx — refresh spinner, consistent surface
  • packages/ui/src/new-data-pill.tsx — "X new" tap-to-refresh affordance
  • packages/ui/src/offline-toast.tsx — banner when offline + queued mutations
  • packages/app/src/presentation/hooks/use-sync-status.ts — aggregates isFetching + queued count + onlineManager state

Modified files:

  • packages/ui/src/main-tab-shell.tsx — host the badge in header right
  • Each top-tab screen — opt-in to <NewDataPill> for relevant data streams

Design

The useSyncStatus() hook:

ts
export function useSyncStatus() {
  const isFetching = useIsFetching(); // TanStack built-in
  const queuedMutations = useMutationQueue((q) => q.size);
  const isOnline = useOnlineStatus();
  return { isFetching, queuedMutations, isOnline };
}

Risk register

RiskSeverityMitigation
Indicators are too noisyMEDIUMThreshold: only show "refreshing" if fetch lasts >300ms
New-data pills accumulate visuallyLOWPill auto-dismisses after 10s if untouched

Validation

  • Manual smoke per screen
  • User feedback collected via in-app survey

Bake gate before 14f

  • 5 days in preview
  • User feedback indicates indicators feel "Toss-like" (subtle, informative)

Phase 14f — Per-feature freshness contract audit

Goal

Every query and mutation hook has a documented freshness policy. ESLint enforces. Drift caught at lint time.

Why last

Requires all infrastructure (L1–L5) to be in place. Without persistence (L2), there's no meaningful "freshness" to declare. Without queue (L3), there's no meaningful "offline" behavior to declare.

Prerequisites

  • All previous phases shipped + baked

Scope

The contract block — required at the top of every query/mutation hook:

ts
/**
 * @freshness 5min — sessions don't change often once locked
 * @refetchOnFocus true — coming back to a club may show new sessions
 * @realtime none — no persistent channel; focus/foreground refresh catches changes
 * @offline read-from-cache — list browseable offline
 */
export function useClubSessions(clubId: string) { ... }
ts
/**
 * @optimistic clubKeys.members — flip role badge instantly
 * @offline queue-and-replay — RSVP works on subway
 * @conflict show-toast — alert user if replay fails
 */
export function useUpdateMemberRole() { ... }

New files:

  • eslint-plugin-twomore/rules/require-freshness-contract.ts — flags hooks missing the block

Modified files:

  • 61 query hooks — add doc comment
  • 46 mutation hooks — add doc comment

Risk register

RiskSeverityMitigation
Audit is mechanical and tediousLOWESLint autofix provides default block; engineer fills in true values
Doc comments drift from realityMEDIUMLint rule eventually enforces shape; full enforcement deferred to Phase 14f.1

Validation

  • ESLint passes with new rule
  • Sample hooks reviewed for accuracy
  • CLAUDE.md updated with the contract format

Cross-cutting concerns

Observability

Each phase ships with telemetry to validate the gate:

  • 14a: Sentry breadcrumbs on migration success/failure with key counts
  • 14b: cache hit ratio tracking on cold start
  • 14c: AppState transition counts + reconnect catch-up timing
  • 14d: queue size, replay success rate, conflict rate
  • 14e: indicator-shown counts + tap-through rate

Branching strategy

Each phase ships on its own branch (e.g. phase-14a-mmkv), merged via PR after CI green, baked in preview channel for the gate duration before next phase begins.

Documentation deliverables per phase

  1. PR description with the design rationale
  2. Update to docs/rebuild-log.md with phase entry (Goals / What shipped / Validation / Bake gate)
  3. Update to relevant CLAUDE.md rules
  4. Phase-specific guide in docs/guides/networking-foundation/<phase>.md for future engineers

Total timeline

  • ~12 working days of implementation
  • ~6 weeks of total bake time across all phases
  • End-to-end: ~8 weeks calendar time

This is appropriate for foundational backend work that touches every feature. Compressing it risks the kinds of bugs that erode user trust and require emergency rollbacks.


What this plan is NOT

  • Not a rewrite of any feature. Features adopt the new infrastructure incrementally as L6 audit lands; existing code keeps working throughout.
  • Not parallel work. Strict sequential per the plan above. Each phase depends on the previous; bugs at lower layers are diagnosed in isolation.
  • Not speculative. Every decision references either TanStack Query / Zustand / Supabase docs, or established RN apps that ship this exact architecture (Toss, Kakao Talk, Linear, Twitter/X).

Approval gate

Before phase 14a begins, the following must be confirmed:

  1. ✅ Owner sign-off on this plan
  2. react-native-mmkv v3.x compatibility with Expo SDK 55 + Fabric verified
  3. ✅ EAS build profile updated to include MMKV native module
  4. ✅ Test fixture: app build with seed data in AsyncStorage to validate migration

When all four are met, phase 14a begins.

Markdown remains the source of truth. Run yarn docs:check before handoff.