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.mdis 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:
- Actively watched — the user dwells on a screen watching THIS data change (not glance-and-leave).
- Latency-critical — seconds of staleness degrade the experience or correctness.
- 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). ManualsetInterval + invalidateQueries(not per-queryrefetchInterval) is used because these 6 queries are shared with other screens — a per-query interval would poll them while the scorecard is NOT focused. 60sstaleTimeis the background fallback. Surfaces: live match scores + score-calls + session status + session RSVP, all viauseLiveSessionData. 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.tsbustssignalKeys.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.tsbusts the relevant entity keys on tap/foreground push); (4) foreground catch-up (useAppForegroundRefresh, Protocol DisStale()predicate — renamed fromuseRealtimeAppState2026-06-01) — covers any stale observer on app-resume; (5) mutation-side optimistic patches +onSettledinvalidation 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 ANDisStale()-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 +
onSettledinvalidation of the relevant keys (INCLUDING thebyUser/list keys other actors read); cross-actor changes surface on next focus/mount/reconnect withinstaleTime. 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 — theno-pii-keys-in-jsonb-writesrule covers the write side). realtime.subscribeis allowed ONLY insidepresentation/hooks/realtime/**(enforced byno-realtime-subscribe-outside-realtime-hooks) — and every such hook MUST be focus-gated (useFocusedEffect).hooks/realtime/dir is absent (2026-06-01:use-dm-realtimedeleted); the LIVE-tier scorecard/match-board uses focus-gated 10s manual-invalidate polling (useLiveSessionData, stops when terminal); DM uses nativerefetchInterval. Therealtime-focus-gatearch-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 onprop === 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."tsxfunction 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/*Itemare 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
*.allprefix.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 (seepackages/app/src/presentation/hooks/use-app-foreground-refresh.ts):queryClient.invalidateQueries({ type: 'active', predicate: (q) => q.isStale() }). Common misconception (do not repeat): bareinvalidateQueries({ 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 (detachInactiveScreenskeeps 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. Thepredicate: (q) => q.isStale()restores the intended semantics — only observers already past their own staleTime refetch. (refetchQueriesis even worse — it refetches regardless ofisInvalidatedtoo.)Protocol E — Selector over second-fetch. When children need a derived view of parent data (count, filter, mapping), use
useMemoover the parent's already-fetched data OR TanStack'sselectoption on the sameuseQuerykey — 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'isFetchedflags 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. UseisFetchednotisLoadingso 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 ononPressIn(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 topackages/app/src/presentation/cache/prefetch.tsthat 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-levelsafeMap(rows, mapper, context)frompackages/app/src/adapters/supabase/base.adapter.tswraps every adapter list-query.map()site and drops individual rows that throw, logginglogger.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-fieldsafeParse(...).data ?? fallbackfor 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 (safeMapthen drops the row). Single-rowfindById/.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 usingsafeMap(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))inpackages/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. EverysetInterval/ 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-effectwithin the app package) — runs the effect only while the consuming screen is currently focused, pauses on blur, resumes on focus. Internally wrapsuseEffect+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 viauseFocusedEffectinternally (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. Therealtime-focus-gatearch-test asserts it stays absent, and@twomore/require-is-focused-in-realtime-hookvalidates any future hook if the directory returns. - On focus re-resume: no blanket manual invalidate is needed. TanStack's
refetchOnWindowFocuscovers app-foreground catch-up; navigation-focus catch-up happens naturally viastaleTimeon the next observer notification.
- The primitive:
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)frompackages/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 whensession.statusis'completed'/'cancelled'), Protocol-G ready-gate combining all 4isFetchedflags. 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)frompackages/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 }whereroleis the fullUseClubRoleReturn(canManage* flags +hasPermission+isMember) andqueriesis 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 + inlinemembers.some(m => m.userId === uid && m.isActive)isMember derivation.usePlayerProfileData(userId)frompackages/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 spreadsqueriesinto its<QueryBoundary>. NOT used by public-profile-screen (it reads only profile + a 5-row preview + a pairwiseuseFriendship, 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:
- Identify the entity (live session, user activity, club detail, etc.)
- List the canonical queries + polling interval (if LIVE-tier) + ready-gate for it
- Wrap them in a
useXxxData(id)composer inpresentation/hooks/composites/returning the data slices +ready/isError/retry+ the critical-pathqueries[](for<QueryBoundary>composition) + anyrefetch*callbacks - Migrate every screen consuming the entity to the composer
- 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
- Best Practices › Networking & Caching — generic deep-dive on TanStack Query patterns, cache shapes, and adapter wiring.
- docs/adr/0005-network-orchestration-protocols.md — ADR for the network orchestration protocols.
- AGENTS.md — ARCH-6, DATA-9..DATA-12 (machine-enforced network and caching constraints).
- CLAUDE.md — orchestration core + the pointer index back to this doc.