Skip to content

Networking Conventions

Status: Active Last reviewed: 2026-06-30

App-specific protocol layer for how TwoMore keeps data fresh — the four freshness tiers (LIVE / NOTIFY / AMBIENT / ON-DEMAND), the twelve fan-out and UX protocols (A–L), and the three canonical composer hooks. This doc covers the decision rules; docs/best-practices/networking-and-caching.md is the generic deep-dive on TanStack Query patterns, cache shapes, and adapter wiring. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (ARCH-6, DATA-9..DATA-12).

Freshness tiers — when to use realtime vs push vs focus-refetch vs cache (FOUNDATION)

Every data surface belongs to exactly ONE freshness tier, and the tier dictates the mechanism. This is the foundation the Network Protocols below operate within: protocols A-L shape HOW a tier's fetches are bounded; the tier decides WHICH mechanism keeps the data fresh. Origin: a 2026-05-22 deep-dive found realtime (Supabase Postgres Changes) over-applied to low-urgency data — dues, attendance, manner-tags, posts, payments — each costing an RLS check per change per subscriber (Supabase docs: "100 subscribers + 1 insert = 100 reads"), processed single-threaded. Realtime is the most expensive freshness tool and must be rationed.

The decision rule — realtime (a persistent Postgres-Changes channel) is justified ONLY when all three hold:

  1. Actively watched — the user dwells on a screen watching THIS data change (not glance-and-leave).
  2. Latency-critical — seconds of staleness degrade the experience or correctness.
  3. Multi-actor — the change comes from someone else (if you're the only writer, optimistic + local invalidation is enough).

If any condition fails, use the cheaper correct tool. The four tiers:

  • LIVE — focus-gated poll. Actively-watched, latency-critical, multi-actor. No persistent Postgres-Changes channels — the scorecard / match-board / live banner use a focus-gated 10s manual-invalidate poll inside useLiveSessionData (pauses when screen is blurred or session reaches a terminal state: completed/cancelled; resumes on focus via Protocol L). Manual setInterval + invalidateQueries (not per-query refetchInterval) is used because these 6 queries are shared with other screens — a per-query interval would poll them while the scorecard is NOT focused. 60s staleTime is the background fallback. Surfaces: live match scores + score-calls + session status + session RSVP, all via useLiveSessionData. Per-subscriber RLS cost is avoided entirely (no socket channels open).
  • NOTIFY — FCM push + cache-bust. "Something happened, tell me." Mobile OS closes WebSockets on background, so a notification's delivery mechanism is a push notification, not a socket. The in-app badge stays fresh via: the push-received/tapped handler invalidating the keys (use-notification-handler.ts busts signalKeys.active + unreadCount) + the app-foreground catch-up (signals carry 60s staleTime → refetched on resume) + refetchOnMount. Surface: notification bell (signals). No persistent realtime channel.
  • AMBIENT — scoped refetch-on-focus (push + staleTime). Glance-and-leave shared state on a frequently-revisited screen. Freshness is carried by: (1) staleTime: STALE_TIME.realtime (60s) on the screen's primary queries — they self-invalidate when an observer is active; (2) refetchOnWindowFocus + refetchOnReconnect (global TanStack defaults — ON) — returning from background or network reconnect refetches any stale observer; (3) push-triggered invalidation (use-notification-handler.ts busts the relevant entity keys on tap/foreground push); (4) foreground catch-up (useAppForegroundRefresh, Protocol D isStale() predicate — renamed from useRealtimeAppState 2026-06-01) — covers any stale observer on app-resume; (5) mutation-side optimistic patches + onSettled invalidation for own writes. No realtime channels — the home AMBIENT channels were removed 2026-06-01 after measuring per-subscriber RLS cost on high-frequency tables. Surface: home session/match/RSVP state — push+staleTime+refetchOnFocus. NB if you use a scoped focus-refetch: it MUST be scoped to the screen's keys AND isStale()-gated — an unscoped/ungated one reintroduces the foreground burst (Protocol D).
  • ON-DEMAND — staleTime + optimistic + invalidate-on-mutation. Not actively watched on its screen (you read it, you don't watch it change); the viewer is usually the mutator. Your own writes feel instant via optimistic patch + onSettled invalidation of the relevant keys (INCLUDING the byUser/list keys other actors read); cross-actor changes surface on next focus/mount/reconnect within staleTime. Surfaces: dues, attendance, manner-tags, session-payments, board posts/comments/reactions. No realtime channel — even a focus-gated one is wasted when nobody's watching the data change.

Realtime anti-patterns removed to date: (1) ALWAYS-ON channels not gated on focus (2026-05-22) — signals, session_payments were mounted at the app shell; moved to NOTIFY + ON-DEMAND respectively. (2) Realtime for unwatched data (2026-05-22) — board posts, dues, attendance, manner-tags moved to ON-DEMAND. (3) Home AMBIENT channels + ALL remaining Postgres-Changes channels (2026-06-01, owner decision) — all 8 no-op stub files deleted; live screens switched to focus-gated 10s polling (useLiveSessionData, stops when terminal); use-dm-realtime deleted — DM uses native refetchInterval (thread screen 5s, inbox 15s) instead. No Postgres-Changes subscriptions remain; hooks/realtime/ dir deleted.

Security / pipeline invariants:

  • No persistent Postgres-Changes channels exist in the app (all retired 2026-06-01). If a future channel is added, reintroduce the auth/socket lifecycle deliberately and keep it RLS-gated; do not copy the pre-retirement auth/registry wiring pattern from old docs (see git history before 2026-06-01 for the retired shape).
  • Every refresh path invalidates ONLY the current user's keys — never a cross-user prefix.
  • No PII in realtime payloads (carry userId, resolve names at read time — the no-pii-keys-in-jsonb-writes rule covers the write side).
  • realtime.subscribe is allowed ONLY inside presentation/hooks/realtime/** (enforced by no-realtime-subscribe-outside-realtime-hooks) — and every such hook MUST be focus-gated (useFocusedEffect). hooks/realtime/ dir is absent (2026-06-01: use-dm-realtime deleted); the LIVE-tier scorecard/match-board uses focus-gated 10s manual-invalidate polling (useLiveSessionData, stops when terminal); DM uses native refetchInterval. The realtime-focus-gate arch-test asserts the dir stays empty and validates any future hook.

When adding a data surface: classify its tier FIRST (apply the 3-condition rule), then wire the tier's mechanism. Reaching for realtime is the exception that must clear all three conditions, not the default.

Network protocols (canonical fan-out rules)

These six protocols compose to keep network traffic bounded and predictable. Apply them whenever you touch a query hook, a list-rendered component, or a realtime subscription. Origin: the 2026-05-14 network audit found a 40-match scorecard firing ~120 parallel HTTP requests because per-item hooks lived inside list-rendered children.

  • Protocol A — Bulk-fetch hook always exists. Every per-item query hook MUST have a bulk sibling at parent scope. Naming convention: useXxx(parentId, itemId)useXxxByList(parentId): Map<itemId, Xxx> (or array — caller memoizes into a Map). If the per-item hook has no bulk sibling, that's the bug — add the bulk hook (port method + adapter + presentation hook) in the same commit before touching consumers. Examples already in tree: useMyRsvp(userId, sessionId)useMyRsvps(userId).

  • Protocol B — Prop-or-fetch convention for list-rendered components. Any component that may render inside a .map() MUST expose its per-item dependencies as optional props. The internal hook becomes a FALLBACK gated on prop === undefined. Three guarantees: (1) incremental migration — existing call sites keep working; (2) standalone rendering still works (detail/preview); (3) bulk-pass is the explicit signal "this row is part of a list."

    tsx
    function SessionCard({ session, userId, myRsvp: myRsvpProp }) {
      const fallback = useMyRsvp(userId ?? '', session.id, {
        enabled: myRsvpProp === undefined && Boolean(userId),
      });
      const myRsvp = myRsvpProp !== undefined ? myRsvpProp : fallback.data;
      // ...
    }

    Components named *Card / *Row / *Item are presumed list-rendered. New per-item hooks added inside them without a corresponding prop are a bug.

  • Protocol C — Event precision. For any future realtime/push/manual-poll event handler: (1) scope the upstream fetch or event as narrowly as the source supports; (2) client-filter via a known id Set when the event doesn't carry the parent's id directly; (3) invalidate ONLY the keys the event actually changed — never the parent *.all prefix.

  • Protocol D — Foreground catch-up respects staleTime via an isStale() predicate. The foreground/resume catch-up MUST gate on staleness, NOT fire a blanket invalidation. Correct form (see packages/app/src/presentation/hooks/use-app-foreground-refresh.ts): queryClient.invalidateQueries({ type: 'active', predicate: (q) => q.isStale() }). Common misconception (do not repeat): bare invalidateQueries({ type: 'active' }) does NOT "only refetch stale queries" — it force-refetches EVERY active observer immediately regardless of freshness (it marks them stale AND triggers an active refetch). On a navigation-heavy app where screens stay mounted (detachInactiveScreens keeps the React tree), the active-observer set grows, so a blanket catch-up fires a progressively larger burst that saturates the concurrency limiter into multi-second tails. The predicate: (q) => q.isStale() restores the intended semantics — only observers already past their own staleTime refetch. (refetchQueries is even worse — it refetches regardless of isInvalidated too.)

  • Protocol E — Selector over second-fetch. When children need a derived view of parent data (count, filter, mapping), use useMemo over the parent's already-fetched data OR TanStack's select option on the same useQuery key — never a separate query that re-fetches a slice of what's already cached. Pure-CPU derivations are not I/O; routing them through TanStack just churns cache keys.

  • Protocol F — Detail reads list cache as placeholderData. When the user navigates from a list to a detail screen, the detail's first-fetch should not refetch if the list cache already has the row. Pattern: placeholderData: () => listCache.find(i => i.id === detailId). Combine with TanStack's stale-while-revalidate so the placeholder paints instantly while the detail-specific fetch revalidates in background.

Foreground UX protocols (perceived-latency rules)

Network Protocols A-F shape what data is fetched and when. These four UX Protocols shape how the user perceives the loading process. They are orthogonal — a screen can have perfect network shape and still feel laggy if it ignores G/H/I, and a screen can hide good network shape behind sloppy UX. Keep the layers separate: never solve a network problem by changing the UI loading pattern, and never solve a UX problem by re-shaping queries.

  • Protocol G — Screen-level readiness gating. A screen's content area renders all sections together, not section-by-section. Cause of the anti-pattern: each section fires its own useQuery, queries resolve at different times, and sections that resolve to "empty" still flash a skeleton-then-null reveal. Fix: combine the critical-path queries' isFetched flags at the screen root and render a unified skeleton until ALL settle. Sub-sections (via DataSection) still own their final empty/content state — Protocol G just removes the cascading reveal. Use isFetched not isLoading so empty results count as ready. Critical path = the queries the screen's primary surface visually depends on; secondary background queries (achievements counts, friend lists) can resolve independently. Apply to surfaces where the user reads the whole screen as one unit (profile, records, club-detail home tab).

  • Protocol H — Anticipatory prefetch on onPressIn. Any pressable that navigates to a detail screen MUST call the destination's prefetch helper on onPressIn (press intent, ~100ms before navigation actually starts). The prefetch fires the destination's primary queries in parallel; by the time the route transition animation completes, the cache is warm. Wiring template:

    tsx
    <Pressable
      variant="card"
      onPressIn={() => prefetchSpectatorScorecard(queryClient, session.id)}
      onPress={() => appRouter.push(routes.scorecard(session.id) as never)}
    >

    For each detail screen, add a prefetchXxxScreen(client, id) helper to packages/app/src/presentation/cache/prefetch.ts that fires every primary query the screen mounts. Combine with Protocol F (placeholderData from list cache) so a destination renders instantly with cached data even before prefetch resolves.

  • Protocol I — Foreground vs background separation. Network Protocols (A-F) are background concerns — what to fetch, how to dedup, when to invalidate. UX Protocols (G-J) are foreground concerns — what the user sees during loading and transitions. Do not conflate them. A failing UX (laggy tap, piecewise reveals) is often misdiagnosed as a network shape problem. Before refactoring a query, ask: is this a fetch shape failure, or a perception failure? If the data is already cached but the screen still feels slow, the fix lives in G/H/J, not A/B/C.

  • Protocol J — Information architecture: screens have one job. Each top-level tab has a primary mental model the user assigns to it. 홈 = today's activity. 클럽 = my clubs + discovery. 경기 = matches I'm playing/RSVP'd. 기록 = analytics + leaderboards + personal records. 프로필 = identity + social + settings. Sections drift across screens over time; periodically audit "is this card on the right screen?" Analytics content (PersonalRecordsSection — biggest win, current streak, busiest week, best ELO swing) belongs on 기록, NOT 프로필. The check: if removing the section makes the host screen MORE focused on its primary job, the section should move.

  • Protocol K — Mapper resilience. A single Zod schema failure inside .map(toDomain) must not crash the whole query. Two complementary patterns: (1) boundary-level safeMap(rows, mapper, context) from packages/app/src/adapters/supabase/base.adapter.ts wraps every adapter list-query .map() site and drops individual rows that throw, logging logger.warn + a Sentry breadcrumb keyed to the entity context (e.g. 'adapter', 'safeMap:club.list dropped row') so the breadcrumb is searchable in the Sentry dashboard; (2) per-field safeParse(...).data ?? fallback for nullable enums and required-with-default fields in mapper files, leaving .parse() only for required-no-default fields where the row is fundamentally broken without that field (safeMap then drops the row). Single-row findById / .maybeSingle() paths are deliberately NOT wrapped — failure there should propagate so screens render an error state instead of pretending the row didn't exist. Add new bulk-query adapter methods using safeMap(rows, xMapper.toDomain, '<entity>.<scope>') so the context label is consistent and searchable. Enforced by @twomore/require-safemap-in-adapter-list (Wave H) — flags any bare .map(row => x.toDomain(row)) in packages/app/src/adapters/; the rule surfaced 6 residual unwrapped sites that a manual grep sweep had missed, validating mechanical enforcement over hand-audit.

  • Realtime is not a default dependency. The app must work fully without persistent sockets. Current data freshness is carried by TanStack staleTime/refetch-on-focus, push-triggered cache busts, focused polling for live surfaces, native DM refetchInterval, optimistic writes, and targeted invalidation. A future socket-based hook needs an explicit freshness-tier justification and must remain an enhancement over the query source of truth.

  • Protocol L — Pause-on-blur for active subscriptions. The bottom tabs use detachInactiveScreens: true, which removes inactive screens from the native view tree but keeps the React component tree mounted. Every setInterval / realtime subscription on a visited screen therefore stays live in the background, accumulating callback cost as the user navigates around. Every hook that subscribes to an external system MUST gate its subscription using the focus-aware primitive:

    • The primitive: useFocusedEffect(effect, deps) from @twomore/app (or @/presentation/hooks/use-focused-effect within the app package) — runs the effect only while the consuming screen is currently focused, pauses on blur, resumes on focus. Internally wraps useEffect + useIsFocused. Every clock-ticker and realtime hook in the codebase uses this primitive so the focus-gate behavior stays consistent across the app. New hooks that subscribe to external systems MUST use it (enforced by @twomore/require-is-focused-in-realtime-hook).
    • Clock-tickers: built into packages/app/src/presentation/hooks/use-now-ticking.ts, packages/app/src/presentation/hooks/use-live-countdown.ts, packages/app/src/presentation/hooks/use-live-countdown.ts (useDeadlineCountdown), packages/app/src/presentation/hooks/use-shared-ticker.ts — all via useFocusedEffect internally (preferred for multi-consumer cadences — collapses N per-row setIntervals into 1 broadcaster per cadence so React 18 batches all updates into one commit).
    • Realtime hooks: packages/app/src/presentation/hooks/realtime/ is absent after the 2026-06-01 retirement. The realtime-focus-gate arch-test asserts it stays absent, and @twomore/require-is-focused-in-realtime-hook validates any future hook if the directory returns.
    • On focus re-resume: no blanket manual invalidate is needed. TanStack's refetchOnWindowFocus covers app-foreground catch-up; navigation-focus catch-up happens naturally via staleTime on the next observer notification.

Network Orchestration — composer hooks

The Network Protocols A-L describe individual rules. The composer hooks codify the COMPOSITION — how the rules apply together for a specific domain. Each composer owns the network shape (queries + realtime + ready-gate) for one logical entity:

  • useLiveSessionData(sessionId) from packages/app/src/presentation/hooks/composites/use-live-session-data.ts — canonical setup for any screen displaying a live session: 4 queries (session + matches + rsvps + profiles), focus-gated 10s manual-invalidate poll (stops when session.status is 'completed'/'cancelled'), Protocol-G ready-gate combining all 4 isFetched flags. Returns { session, matches, rsvps, clubProfiles, ready, isRefetching, refetchSession, refetchMatches }. Used by session-detail-screen, spectator-scorecard-screen, match-board-screen, and live-session-stack on home. TanStack dedupes across consumers via cache keys.
  • useClubContext(clubId) from packages/app/src/presentation/hooks/composites/use-club-context.ts — canonical setup for any club detail surface: useClub + useClubMembers + useClubRole (which itself re-subscribes to club+members+auth — TanStack-deduped, so zero new network cost), Protocol-G ready-gate over club+members. Returns { club, members, userId, role, ready, isError, retry, queries } where role is the full UseClubRoleReturn (canManage* flags + hasPermission + isMember) and queries is the [clubQ, membersQ] objects to spread into a screen's <QueryBoundary> alongside screen-specific queries. Used by club-detail, club-members, club-dues, club-attendance, club-board. Eliminates the repeated useAuth+useClub+useClubMembers+useClubRole quartet + inline members.some(m => m.userId === uid && m.isActive) isMember derivation.
  • usePlayerProfileData(userId) from packages/app/src/presentation/hooks/composites/use-player-profile-data.ts — canonical setup for the profile/records cluster: useProfile + useMatchHistory + useFriends, Protocol-G ready-gate over all three. Returns { profile, matches, friends, ready, isError, retry, queries }. Used by profile-screen, records-screen, records-history-screen — each adds its screen-specific hooks (achievements / manner-tags / player-stats-detail / my-clubs) on top and spreads queries into its <QueryBoundary>. NOT used by public-profile-screen (it reads only profile + a 5-row preview + a pairwise useFriendship, sharing no friends list — two hooks isn't boilerplate worth a composer).

New screens displaying the same entity MUST use the composer — never re-implement the boilerplate. Drift between screens is the failure mode the composer prevents.

Pattern for new composers:

  1. Identify the entity (live session, user activity, club detail, etc.)
  2. List the canonical queries + polling interval (if LIVE-tier) + ready-gate for it
  3. Wrap them in a useXxxData(id) composer in presentation/hooks/composites/ returning the data slices + ready/isError/retry + the critical-path queries[] (for <QueryBoundary> composition) + any refetch* callbacks
  4. Migrate every screen consuming the entity to the composer
  5. Document the composer in CLAUDE.md's Network Orchestration section

Future best-practice migration (not yet done): TanStack v5's queryOptions factory is the recommended building block for sharing a query's config between a hook, a prefetch helper, and a composer (one source of truth for queryKey + queryFn + staleTime, fully typed). Our query hooks currently inline useQuery({...}); migrating them to export xxxQueryOptions(id) and having hooks/prefetch/composers consume those would remove the last duplication (queryKey ↔ prefetch ↔ hook). Tracked as a follow-up — do it incrementally as query hooks are touched.

See also

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