Table of Contents
- The Startup Architecture — Two-Gate System
- Parallel Init — Never Block on Sequential Awaits
- The Provider Tree — Order Matters
- Session State Machine
- Auth Flows — Login & Create Account
- The Shell Big Bang — Background Services on Login
- Push Notification Pipeline
- Notification Delivery Strategy
- Notification Unread Count Polling
- DM Messaging Bus Architecture
- Analytics — Batching & Passive Tracking
- Onboarding State Machine
- Home Feed — Polling & Live Updates
- Multi-Account Architecture
- Error & Session Drop Handling
- The Complete System Map
- Must-Follow Rules — Derived from Real Patterns
1. The Startup Architecture — Two-Gate System
One of the key finding from the analysis is that the UI visibility depends on two independent gates. Understanding and implementing both correctly is essential.
Gate 1: App() component - Storage + Geo + Device ID
File: src/App.native.tsx — Lines 218–222
Promise.all([
initPersistedState(), // Opens MMKV, reads all saved state into memory
Geo.resolve(), // Country detection (for feature gating)
setupDeviceId, // Reads/generates anonymous analytics ID from SecureStore
]).then(() => setIsReady(true))The app waits for all three to finish before setting isReady to true. Until then, App() returns null, so no React UI is rendered and the native splash screen remains visible.
This is intentional. It guarantees that persisted state, location-based feature flags, and the device identifier are all available before the application begins rendering, preventing inconsistent or partially initialized state during startup.
Gate 2 — InnerApp() component: Session Resume + Landing Check
File: src/App.native.tsx — Line 148
<Splash isReady={isReady && hasCheckedLanding}>The splash only hides once both of these conditions are true:
isReady— The session resume process has finished, regardless of whether it succeeded or failed. This flag is typically set in afinallyblock so startup can continue even if session restoration fails.hasCheckedLanding— The app has finished resolving its initial destination, including any deep links, notification taps, or starter pack invites.
Why a Second Gate?
Completing app initialization isn't enough. Before the UI becomes visible, the app also needs to know where the user should land.
If someone opens the app from a push notification or a starter pack invite, briefly rendering the default home screen before redirecting creates a noticeable flicker and a poor user experience. Even a 100 ms flash of the wrong screen feels broken.
By waiting until both the session state and the landing destination are resolved, the splash screen stays visible until the app can navigate directly to the correct screen. The user sees the right destination from the very first frame, with no intermediate UI or unnecessary redirects.
What Fires Before Any Component (Module Level)
T=0ms: Sentry initialized (import side-effect — first line of the file)
T=2ms: SplashScreen.preventAutoHideAsync() — splash LOCKED
T=3ms: iOS: SystemUI.setBackgroundColorAsync('black') — prevents white flash
T=3ms: Android: ScreenOrientation.lockAsync(PORTRAIT_UP)
T=5ms: void Geo.resolve() — HTTP request, fire-and-forget
T=5ms: void prefetchAgeAssuranceConfig() — HTTP request, fire-and-forget
T=5ms: void prefetchLiveEvents() — HTTP request, fire-and-forget
T=5ms: void prefetchAppConfig() — HTTP request, fire-and-forgetWhy use void?
These network requests are intentionally not awaited. Prefixing them with void makes it explicit that they run independently and their completion is not required for startup.
This has two major benefits:
React initialization is never blocked. Component mounting, provider setup, and state restoration continue immediately.
Data is often ready before it's needed. By the time components request these resources, the responses are frequently already cached, reducing perceived load times.
The guiding principle is simple: if a task isn't required to render the first screen, don't block startup waiting for it. Start it as early as possible and let it complete in the background.
Rule: If a task isn't required to render the first screen, don'tawaitit. Start it as early as possible usingvoidso it runs in parallel with React initialization. By the time the UI needs the data, it's often already cached, reducing startup latency without blocking the app.
2. Parallel Init — Never Block on Sequential Awaits
A consistent pattern throughout the app is that independent tasks always run in parallel. Startup time is determined by the slowest task, not the sum of every task.
What runs in parallel at app boot
//❌ Wrong (Sequential)
await initPersistedState();
await Geo.resolve();
await setupDeviceId();
//✅ Correct (Parallel)
await Promise.all([
initPersistedState(),
Geo.resolve(),
setupDeviceId(),
]);What runs in parallel when hasSession becomes true (Shell mounts)
At the moment hasSession = true, ALL of these fire simultaneously:
- useIntentHandler() — Deep link URL parser
- setSystemUITheme() — Status bar color sync
- <PassiveAnalytics /> — AppState foreground/background listener
- useNotificationsRegistration() — APNs/FCM token fetch + server registration
- useNotificationsHandler() — 11 Android notification channels created, tap listener registered
- BackHandler (Android) — Hardware back button registered
- UnreadNotifsProvider — Immediate notification count fetch + 30s interval started
- MessagesEventBus — DM polling initialized + 2s interval started
The UI is shown immediately while these services initialize in the background.
Design principle: Do not wait for any of these to complete before showing the UI. They are all "best-effort" background services. The user sees the feed immediately.
At post-auth initialization, run in parallel
// After resumeSession or login succeeds — never await these in sequence
await Promise.allSettled([
fetchCurrentProfile(), // Needed for nav header
fetchPreferences(), // Feed pins, content labels, language
fetchNotificationCount(), // Tab bar badge
fetchUnreadConvoCount(), // DM badge
registerPushToken(), // Non-blocking background
checkServerCapabilities(), // Feature flags
])
// allSettled — never block the UI if one fails
// Promise.allSettled() ensures one failed request never blocks the rest of the app.Rule: If tasks don't depend on each other, run them in parallel. UsePromise.all()when every task is required, andPromise.allSettled()when failures shouldn't block the user experience.
3. The Provider Tree — Order Matters
From the analysis, the app mounts 30+ Context Providers in a specific order. The provider hierarchy is a dependency graph, not an arbitrary nesting. A provider can consume values from providers above it, but never from those below.
Geo.Provider ← country data
AppConfigProvider ← remote feature flags (reads Geo for gating)
A11yProvider ← accessibility settings
KeyboardControllerProvider
OnboardingProvider ← reads MMKV: onboarding.completed
AnalyticsContext ← MetricsClient + 10s flush timer STARTED HERE
SessionProvider ← SessionStore, reads MMKV accounts synchronously
PrefsStateProvider ← theme, feeds, content labels
I18nProvider ← translations for device locale
ShellStateProvider
DialogStateProvider
PortalProvider ← outlet for modals outside component tree
BottomSheetProvider
LandingProvider ← deep link / starter pack detection
SafeAreaProvider ← notch/home bar insets (synchronous)
<InnerApp />Key Dependencies
AppConfigProviderreads country information fromGeo.Provider.SessionProvideris nested insideAnalyticsContextso authentication events can be tracked.PrefsStateProviderdepends on session state.PortalProviderstays near the root so dialogs, modals, and onboarding flows can render above the entire app.SafeAreaProviderwraps the app just before rendering to provide device insets.
Critical: SessionProvider must be inside AnalyticsContext so auth events can be tracked. PortalProvider must be near the root so modals (including Onboarding) can render over everything.
4. Session State Machine
The session is not stored in React state — it uses useSyncExternalStore against a custom SessionStore class. This is a key architectural decision.
Why SessionStore instead of React state?
// SessionStore.dispatch() does:
1. Runs reducer (pure function, synchronous)
2. Updates this.state in memory
3. If needsPersist: writes to MMKV synchronously (no React cycle)
4. Calls all useSyncExternalStore subscribers → React re-renders
// Result: MMKV is always written BEFORE the React re-render.
// No race condition where UI updates but storage hasn't saved yet.
// Because MMKV is written before React re-renders, the UI and persisted state never fall out of sync.Session State Transitions
App opens
│
├── readLastActiveAccount() from MMKV
│ │
│ ├── account found → resumeSession(account)
│ │ ├── createAgentAndResume()
│ │ │ └── agent.resumeSession() → HTTP: com.atproto.server.getSession
│ │ │ └── if expired → automatic: com.atproto.server.refreshSession
│ │ └── store.dispatch('switched-to-account') → hasSession = true
│ │
│ └── no account → features.init() → hasSession stays false
│
├── hasSession = false → LoggedOutView
└── hasSession = true → Shell mounts
Token events (onAgentSessionChange callback):
'create' → new session, write tokens to MMKV
'update' → token refreshed, update MMKV silently
'expired' → emitSessionDropped() → toast → user sent to login
'create-failed' → emitSessionDropped()The hasSession Computation
// src/state/session/index.tsx — Lines 350–359
const stateContext = useMemo(() => ({
accounts: state.accounts,
currentAccount: state.accounts.find(a => a.did === state.currentAgentState.did),
hasSession: !!state.currentAgentState.did,
}), [state])When hasSession flips false → true, the entire navigation switches from LoggedOutView to the home feed. This single boolean is the most important value in the app.
Rule: Keep session state outside React and persist it before notifying subscribers. A single source of truth (hasSession) should control the entire authentication flow.5. Auth Flows — Login & Create Account
Login Flow (LoginForm.tsx)
Key implementation decisions from the analysis:
The login and signup flows prioritize responsiveness, clear error handling, and preventing unnecessary re-renders.
1. Use useRef not useState for text inputs
const identifierValueRef = useRef<string>(initialHandle || '')
const passwordValueRef = useRef<string>('')
// Text input changes do NOT trigger re-renders.
// Only read on form submit. Significant performance gain on low-end devices.2. Auto-complete handles
// User types "alice" → gets "alice.bsky.social" automatically
if (!identifier.includes('@') && !identifier.includes('.')) {
fullIdent = createFullHandle(identifier, serviceDescription.availableUserDomains[0])
}3. Request push permissions immediately after login
await login(params, 'LoginForm')
onAttemptSuccess() // Analytics metric
setShowLoggedOut(false) // Reveal home feed
requestNotificationsPermission('Login') // Show system dialog now4. Granular error handling (never one generic "error")
AuthFactorTokenRequiredError → reveal 2FA input field (don't show error, just reveal field)
'Token is invalid' → "Invalid 2FA code"
'Invalid identifier or password' → "Incorrect username or password"
isNetworkError(e) → "Unable to contact your service"Create Account Flow (state machine pattern)
The signup uses useReducer with a full state machine — not multiple useState calls.
// src/screens/Signup/state.ts
enum SignupStep { INFO = 0, HANDLE = 1, CAPTCHA = 2 }
// Every field error fires an analytics metric:
case 'setError':
next.fieldErrors[action.field]++ // Count how many times each field failed
analytics.metric('signup:fieldError', { // → Bluesky knows which fields confuse users
field: action.field,
errorCount: next.fieldErrors[action.field],
activeStep: next.activeStep,
})Double-submit prevention with mutable flag (not state dispatch)
// The CAPTCHA step dispatches pendingSubmit, this effect watches for it:
useEffect(() => {
if (state.pendingSubmit && !state.pendingSubmit.mutableProcessed) {
state.pendingSubmit.mutableProcessed = true // Direct mutation — no re-render
void submit(state, dispatch)
}
}, [state, dispatch, submit])
// mutableProcessed is mutated directly so it doesn't cause another render
// and trigger a second submit.Android: warm up Play Integrity API early
// When Signup screen appears — not when CAPTCHA step appears
// So it's warm by the time the user needs it
useEffect(() => {
if (!IS_ANDROID) return
ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID).catch(...)
}, [])Onboarding only starts AFTER successful account creation
// Inside useSubmitSignup() — Lines 334–340
await createAccount({...}) // Throws on failure
// Reached only on success:
onboardingDispatch({type: 'start'}) // Now safe to show onboardingRule: Keep authentication flows fast and predictable. Avoid unnecessary re-renders, handle each failure explicitly, prevent duplicate submissions, and defer post-login features until authentication succeeds.
6. The Shell Big Bang — Background Services on Login
The moment hasSession = true and Shell mounts, a cascade of background services starts. This is the "Big Bang" moment.
Shell() mounts:
useIntentHandler() → URL intent parser (deep links) STARTED
setSystemUITheme() → system bar colors synced to theme
ShellInner() mounts:
useNotificationsRegistration() → push token pipeline STARTED
useNotificationsHandler() → notification routing STARTED
BackHandler.addEventListener() → [Android] hardware back button REGISTERED
navigation.addListener('state') → [Android] video player release on navigate
Already mounted (from InnerApp):
UnreadNotifsProvider → 30s polling for badge count STARTED
MessagesProvider → 2s polling for DMs STARTED
<PassiveAnalytics /> → AppState session tracking STARTEDRule: None of these can fail silently and block the UI. Wrap each in their own try/catch. The user should see the feed even if services like push notifications registration fail.
7. Push Notification Pipeline
From the analysis — a complete picture of how push tokens flow from device to server:
ShellInner mounts
│
useNotificationsRegistration()
│
getAndRegisterPushToken()
│
├── if (!IS_NATIVE || IS_DEV) return ← Web/Dev skip entirely
│
├── Notifications.getPermissionsAsync()
│ └── if NOT granted → return (skip registration, ask later)
│
├── Notifications.getDevicePushTokenAsync()
│ ├── iOS → contacts Apple APNs servers → 64-byte hex token
│ └── Android → contacts Google FCM servers → registration token
│
└── agent.app.bsky.notification.registerPush({
serviceDid: PUBLIC_APPVIEW_DID,
platform: 'ios' | 'android',
token: token.data,
appId: 'xyz.blueskyweb.app',
ageRestricted: false
})
│
└── HTTP POST → AppView server → Courier microservice
└── stores (userDid → deviceToken) in Courier DB
└── future notifications for this user delivered hereToken Rotation Handling
// APNs/FCM occasionally issue new tokens — auto-handled:
Notifications.addPushTokenListener(async token => {
registerPushToken({ token, isAgeRestricted }) // Re-register new token
})Debounce registration calls
// Wrapped in debounce(100ms) — prevents duplicate registration
// in React Strict Mode or other double-mount scenarios8. Notification Delivery Strategy
From the handler analysis — deliberate choices that should be copied:
Foreground: Suppress banners, update badge silently
// setNotificationHandler is called while app is OPEN
handleNotification: async (e) => {
if (isChatNotification && payload.convoId === currentConvoId) {
// User is already IN this conversation — suppress everything
return { shouldShowBanner: false, shouldShowList: false,
shouldPlaySound: false, shouldSetBadge: false }
}
// All other foreground notifications:
invalidateCachedUnreadPage() // Force badge count re-fetch
return { shouldShowBanner: false, shouldSetBadge: true }
// ^^^^^ intentionally false — never interrupt the user
}Design decision: Never show notification banners when the app is foregrounded. Only update the badge. Users are already using the app — the interruption has negative UX impact.
Background tap routing
// Complete routing map from the analysis:
'like' | 'repost' → Navigate to the specific post
'reply' | 'quote' | 'mention' → Navigate to the reply/mention post
'follow' | 'starterpack-joined' → Navigate to follower's profile
'chat-message' | 'chat-reaction' → Navigate to specific conversation
'chat-removed-from-group' → Navigate to messages list (no longer has access)
// Multi-account notification routing:
if (payload.recipientDid !== currentAccount.did) {
storePayloadForAccountSwitch(payload) // Save navigation target
onPressSwitchAccount(matchedAccount) // Switch to correct account
// After tree remount: stored payload is consumed and navigation fires
}Android Notification Channels (must declare all upfront)
// Must run when ShellInner mounts — before any notification arrives
// Android 8+ requires pre-declared channels
// Users can disable individual channels in system settings
Notifications.setNotificationChannelAsync('chat-messages', {
sound: 'dm.mp3', // Custom sound bundled in APK
vibrationPattern: [250],
importance: AndroidImportance.MAX,
})
// One channel per notification type:
// 'like', 'repost', 'reply', 'mention', 'quote', 'follow',
// 'like-via-repost', 'repost-via-repost', 'subscribed-post'
// + 2 chat variants (with/without sound) = 11 total channels9. Notification Unread Count Polling
From the UnreadNotifsProvider analysis — a sophisticated polling system with multiple optimizations:
On mount: checkUnread() fires immediately (no waiting for first interval)
Every 30s: setInterval(() => checkUnread({isPoll: true}), 30_000)Smart poll-skipping logic (load reduction)
// Guard 1: App is backgrounded
if (AppState.currentState !== 'active') return
// Guard 2: Badge already at max — no point fetching
if (isPoll && unreadCount >= 30) return
// Guard 3: Random 50% skip when there are unread items
// Halves server load for users who aren't clearing notifications
if (isPoll && Math.random() >= 0.5) return
// Only if all guards pass:
await fetchPage({ agent, limit: 40, ... })
setNumUnread(countStr) // Updates tab bar badge
broadcast.postMessage({event: countStr}) // Updates other browser tabsRule: Always apply these three guards to polling. The random 50% skip is particularly clever — it reduces server load proportionally to how many users have pending notifications.
10. DM Messaging Bus Architecture
The DM system uses a two-layer architecture discovered in the analysis:
Layer 1: Global MessagesEventBus (one per login session)
MessagesProvider mounts
│
new MessagesEventBus({ agent })
│
init():
GET getLog({}) → no cursor → returns current latestRev
this.latestRev = "rev_00000100abc"
dispatch(Ready) → startPoll()
│
setInterval(poll, 2_000) ← Active: every 2s
setInterval(poll, slower) ← Backgrounded: much slower (battery saving)
│
poll():
GET getLog({ cursor: this.latestRev })
→ Server returns only events newer than this rev
→ If events found: advance latestRev, emit('event', {type:'logs', logs})
→ Individual Convo agents receive this via their listenersLayer 2: Individual Convo agent (one per open conversation)
User opens DM screen
│
new Convo({ convoId, agent, events: globalEventBus })
│
subscribe() → first subscriber activates:
listenProfileShadowUpdate() → watches for profile changes (blocks, etc.)
init() → dispatch(Init)
│
├── setup() async:
│ void this.fetchMemberList() ← parallel, non-blocking
│ const convo = await this.fetchConvo() ← blocking
│ dispatch(Ready) → fetchMessageHistory()
│ └── getMessages({ limit: 50 }) → fills pastMessages Map
│
├── setupFirehose()
│ this.events.on(handler, { convoId }) ← filtered to THIS convo
│ New messages → ingestFirehose() → commit() → UI re-renders
│
└── requestPollInterval(ACTIVE_POLL_INTERVAL)
← Tells the global bus to poll faster (takes min of all requests)
User closes DM screen:
→ unsubscribe() → subscribers.length === 0
→ withdrawPollIntervalRequest() ← global bus slows back down11. Analytics — Batching & Passive Tracking
MetricsClient flush strategy
// Created when AnalyticsContext provider mounts (very early in boot)
setInterval(() => this.flush(), 10_000) // Every 10 seconds
// Also flush on:
if (queue.length >= 100) this.flush() // Burst protection
// On app background → navigator.sendBeacon() on web ensures deliveryBatched HTTP send structure
fetch(`${METRICS_API_HOST}/t`, {
body: JSON.stringify({
events: batch, // Array of { name, timestamp, properties }
deviceId, // Anonymous stable device ID from SecureStore
appVersion,
platform: Platform.OS,
})
})Passive AppState tracking
// <PassiveAnalytics /> — invisible component in Shell
onAppStateChange(state => {
if (state === 'active') {
lastActive.current = performance.now()
ax.metric('state:foreground', {})
} else if (lastActive.current !== null) {
ax.metric('state:background', {
secondsActive: Math.round((performance.now() - lastActive.current) / 1e3)
})
}
})This is how to get DAU, WAU, MAU and session duration without any external SDK.
Track every signup field error
// In signup reducer — every field error is tracked
analytics.metric('signup:fieldError', {
field: 'email' | 'handle' | 'password',
errorCount: fieldErrors[field], // How many times this field failed
activeStep: state.activeStep,
})
// → Reveals which steps/fields cause the most user drop-off12. Onboarding State Machine
Onboarding renders as a Portal — not inside the navigation stack. It covers the entire screen including the Shell.
Step computation at initialization
// createInitialOnboardingState is the THIRD argument to useReducer
// It receives options and computes which steps to include — once
const [state, dispatch] = useReducer(
reducer,
{ starterPacksStepEnabled: showSuggestedStarterpacks,
findContactsStepEnabled: showFindContacts },
createInitialOnboardingState, // ← Called once, not on every render
)Four-gate "Find Contacts" step
const showFindContacts =
ENV !== 'e2e' // Not in test
&& IS_NATIVE // Web has no contacts
&& findContactsEnabled // Country is legal
&& !ax.features.enabled(ImportContactsOnboardingDisable) // Feature flag not offStep sequence (all API calls happen per step)
- Step 'profile' — agent.upsertProfile({avatar, displayName})
- Step 'interests' — agent.app.bsky.actor.putPreferences({interests})
- Step 'suggested-accounts' — agent.app.bsky.graph.follow({subject}) × N
- Step 'suggested-starterpacks' — Bulk follow all members
- Step 'find-contacts' — Match phone numbers to AT Protocol handles
- Step 'finished' — onboardingDispatch({type: 'finished'}) → save to MMKV + server prefs
Completion persistence
// onboardingDispatch({type: 'finished'}) does TWO things:
1. MMKV: persisted.write('onboarding', { completed: true })
2. Server: agent.app.bsky.actor.putPreferences({ onboardingCompleted: true })
// → Survives uninstall if server preferences are restored on re-login13. Home Feed — Polling & Live Updates
Two-layer update system
// Layer 1: Poll for "there are new posts" every 60 seconds
setInterval(checkForNew, 60_000)
// checkForNew() → calls pollLatest endpoint → lightweight, returns only top post ID
// If newer than current top → hasNew = true → "Load new posts" button appears
// Layer 2: User posts → immediate invalidation (no waiting for 60s poll)
listenPostCreated(() => {
queryClient.invalidateQueries({ queryKey: feedKey })
})
// emitPostCreated() fires from the composer after a successful postRule: Never rely solely on intervals for live data that the user just created themselves. Use EventEmitter to immediately invalidate queries when the local user takes an action.
Feed page structure
Home.tsx
↓ reads pinned feeds from preferences
Pager (swipeable tabs)
↓
FeedPage (per feed)
├── "Load new posts" button (conditional, slides in)
├── FAB (compose button)
└── PostFeed
↓
usePostFeedQuery(feedUri)
├── "Following": agent.app.bsky.feed.getTimeline()
└── Custom: agent.app.bsky.feed.getFeed({ feed: feedUri })14. Multi-Account Architecture
From the analysis — the Fragment key pattern prevents state leaks between accounts:
// InnerApp renders:
<Fragment key={currentAccount?.did}>
{/* Everything inside here */}
</Fragment>When currentAccount.did changes (account switch), React unmounts and remounts the entire tree below. This guarantees:
- No stale TanStack Query cache from the previous user
- No stale agent making API calls for the old user
- No UI state carrying over
Old agent disposal on switch
useEffect(() => {
if (currentAgentRef.current !== agent) {
const prevAgent = currentAgentRef.current
currentAgentRef.current = agent
prevAgent.dispose() // Stops its token refresh loop
// Prevents old agent from competing for refresh tokens with new one
}
}, [agent])Notification cross-account routing
// If a push notification arrives for account B while account A is active:
if (payload.recipientDid !== currentAccount.did) {
storePayloadForAccountSwitch(payload) // Save navigation intent
onPressSwitchAccount(matchedAccount) // Switch to account B
// After Fragment key change + tree remount:
// Stored payload is consumed and navigation fires for the correct screen
}15. Error & Session Drop Handling
The sessionDropped EventEmitter pattern
// Registered on InnerApp mount, lives for entire app session
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(l`Sorry! Your session expired. Please sign in again.`, {type: 'info'})
})
}, [l])This fires when onAgentSessionChange('expired') is called — meaning the refresh token failed. Causes:
- Account was banned/suspended
- Token was revoked from another device
- Server error during refresh
- refreshJwt expired (too long since last use)
finally pattern for gates
// CRITICAL: Always use finally for gate-opening
async function onLaunch(account?: SessionAccount) {
try {
if (account) {
await resumeSession(account)
}
} catch (e) {
logger.error('session: resume failed', {message: e})
// User goes to LoggedOut view — handled by hasSession = false
} finally {
setIsReady(true) // ← ALWAYS runs, app never gets stuck on splash
}
}Rule: Any gate that blocks the UI must open in finally. A crashed session resume must still show the login screen, not a perpetual splash.
16. The Complete System Map
This is the combined map from all three analyzed documents:
T=0ms JS BUNDLE EXECUTES
├─ [SYNC] Sentry crash reporter initialized
├─ [SYNC] Splash screen locked
├─ [SYNC] iOS: black system UI / Android: portrait locked
└─ [ASYNC FIRE] Geo, AgeConfig, LiveEvents, AppConfig — 4 parallel HTTP requests
App() mounts → returns null (splash showing)
│
└─ [PARALLEL WAIT] initPersistedState + Geo.resolve + setupDeviceId
All 3 done → 30+ providers mount in dependency order
├─ AnalyticsContext → MetricsClient 10s flush timer STARTED
├─ SessionProvider → SessionStore, MMKV accounts loaded synchronously
├─ OnboardingProvider → onboarding.completed read from MMKV
└─ 27 more providers init their state
InnerApp() mounts
├─ listenSessionDropped() EventEmitter REGISTERED
├─ readLastActiveAccount() → resumeSession() → AT Proto token refresh
└─ setIsReady(true) + hasCheckedLanding → splash hides
┌─────────────────────────────────────────────────────────┐
│ hasSession = FALSE → LoggedOutView │
│ │
│ Create Account path: │
│ Signup (INFO → HANDLE → CAPTCHA state machine) │
│ → createAccount() → com.atproto.server.createAccount │
│ → store.dispatch('switched-to-account') │
│ → hasSession = true │
│ → onboardingDispatch('start') │
│ │
│ Sign In path: │
│ LoginForm → login() → com.atproto.server.createSession │
│ → store.dispatch('switched-to-account') │
│ → hasSession = true │
│ → setShowLoggedOut(false) │
│ → requestNotificationsPermission() │
└─────────────────────────────────────────────────────────┘
hasSession = TRUE → Shell mounts (THE BIG BANG — all fire simultaneously)
│
├─ useIntentHandler() → deep link parser STARTED
├─ setSystemUITheme() → status bar colors synced
├─ <PassiveAnalytics /> → AppState session tracking STARTED
│
ShellInner mounts
├─ useNotificationsRegistration()
│ ├─ getDevicePushTokenAsync() → APNs/FCM token fetched
│ ├─ agent.registerPush() → token → AppView → Courier DB
│ └─ addPushTokenListener() → auto-reregister on token rotation
│
├─ useNotificationsHandler()
│ ├─ [Android] 11 notification channels CREATED
│ ├─ setNotificationHandler() → foreground interceptor REGISTERED
│ └─ addNotificationResponseReceivedListener() → tap router REGISTERED
│
├─ BackHandler [Android] REGISTERED
└─ navigation state listener [Android] REGISTERED
UnreadNotifsProvider (in InnerApp tree)
├─ checkUnread() fires immediately
└─ setInterval(30s) STARTED (with 3-guard skip logic)
MessagesProvider (in InnerApp tree)
├─ MessagesEventBus init() → latestRev fetched from server
└─ setInterval(2s) STARTED for DM polling
Onboarding Portal (new accounts only)
├─ StepProfile → StepInterests → StepSuggestedAccounts → StepFinished
└─ onboardingDispatch('finished') → saved to MMKV + server → Portal unmounts
Home Feed visible
├─ usePostFeedQuery() → agent.getTimeline() → posts rendered
├─ setInterval(60s) STARTED → new posts polling
└─ listenPostCreated() REGISTERED → immediate invalidation on compose
User opens DM screen
└─ new Convo(convoId)
├─ listenProfileShadowUpdate() REGISTERED
├─ fetchConvo() + fetchMemberList() [PARALLEL]
├─ setupFirehose() → subscribes to MessagesEventBus for this convoId
├─ requestPollInterval(ACTIVE) → global bus polls at 2s
└─ fetchMessageHistory() → last 50 messages loaded → UI renders17. Must-Follow Rules — Derived from Real Patterns
These rules are not theoretical. Each one comes from a specific pattern discovered in the analysis.
🔴 Critical — Ship Blockers
1. Always use finally to open splash/gate states
// From: src/App.native.tsx — Lines 118–134
// If session resume fails, the app must NOT be stuck on the splash screen.
} finally { setIsReady(true) }2. Two gates for splash dismiss, not one
// From: src/App.native.tsx — Line 148
// isReady alone is not enough. Deep link must also be parsed.
<Splash isReady={isReady && hasCheckedLanding}>3. Use Fragment key={currentAccount?.did} for multi-account state isolation
// From: src/App.native.tsx — Lines 152–153
// Without this, switching accounts leaks cache and component state.
<Fragment key={currentAccount?.did}>4. Dispose the old agent on account switch
// From: src/state/session/index.tsx — Lines 386–398
// Two agents competing for the same refreshJwt will corrupt token state.
prevAgent.dispose()5. Never await sequential startup tasks that can be parallel
// From: src/App.native.tsx — Lines 218–222
// Sequential awaits add up. Promise.all() is always correct here.
await Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId])6. Suppress notification banners in foreground
// From: useNotificationsHandler — Lines 307–338
// Notification popups while the user is actively reading = terrible UX.
return { shouldShowBanner: false, shouldSetBadge: true }7. Debounce push token registration
// React Strict Mode double-mounts, rapid re-renders can fire registration twice.
// Debounce(100ms) prevents duplicate token storage on the server.🟡 Important — Quality Signals
8. Start Sentry FIRST — before any other import
// Line 1 of App.native.tsx is an import side-effect, not a function call.
// If the app crashes during init, Sentry must already be active.
import '#/logger/sentry/setup'9. Lock splash screen before React renders anything
// Line 88 — module level, synchronous, before App() mounts
void SplashScreen.preventAutoHideAsync()10. Prefetch everything you need at boot before components mount
// Lines 104–107 — fire-and-forget via void, parallel, no waiting
void Geo.resolve()
void prefetchAgeAssuranceConfig()
void prefetchLiveEvents()
void prefetchAppConfig()11. Use three guards on notification polling
if (AppState.currentState !== 'active') return // Battery
if (isPoll && unreadCount >= 30) return // Max badge
if (isPoll && Math.random() >= 0.5) return // 50% skip = server load halved12. DM polling uses a min-interval request system
Individual conversations request faster poll when active. Bus takes minimum of all requests. Conversation closing withdraws its request — rate returns to default.
13. Onboarding dispatches start ONLY after successful createAccount()
await createAccount({...}) // Can throw
onboardingDispatch({type: 'start'}) // Only reached on success14. Use useRef not useState for text inputs in login forms
Text changes in refs don't re-render. Only read on submit. Matters on low-end Android devices under load.
🟢 Excellence — Differentiators
15. Track signup field errors with analytics
// Know which field causes the most user drop-off.
// Use this to simplify or provide better placeholder text.
analytics.metric('signup:fieldError', { field, errorCount, activeStep })16. Warm up Play Integrity API before the CAPTCHA step
// On Signup screen mount, not on CAPTCHA mount.
// Cold-start delay on CAPTCHA would cause form abandonment.
ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID)17. Cross-account notification routing via stored payload
// Don't drop the notification if it's for a different account.
// Store it → switch account → consume it after remount.
storePayloadForAccountSwitch(payload)
onPressSwitchAccount(matchedAccount)18. Use broadcast.postMessage for cross-tab notification sync
// After fetching unread count, broadcast to other browser tabs.
// Other tabs update their badge instantly without their own polling.
broadcast.postMessage({ event: unreadCountStr })19. Suppress all foreground notifications for the conversation the user is currently in
// From useNotificationsHandler
if (isChatNotification && payload.convoId === currentConvoId) {
return { shouldShowBanner: false, shouldSetBadge: false, shouldPlaySound: false }
}20. Use useSyncExternalStore for session state, not React useState
// SessionStore writes to MMKV synchronously BEFORE React re-renders.
// With useState, there's a window where UI updated but storage hasn't.
const state = useSyncExternalStore(store.subscribe, store.getState)18. Executive Summary
This architecture is built around a simple principle: show the UI as quickly as possible while everything else happens in parallel.
Startup is controlled by a two-gate system that ensures the app has the minimum required state before rendering. Independent initialization tasks run concurrently using Promise.all(), while non-critical work begins immediately in the background without delaying the first screen.
Session management relies on useSyncExternalStore with MMKV, keeping in-memory state and persisted storage synchronized and making authentication and account switching predictable. Multi-account support is isolated using <Fragment key={did}>, ensuring each account gets a fresh provider tree and preventing state leakage.
Once a session is established, the app enters its "Big Bang" phase, where push notifications, messaging, analytics, and other background services start simultaneously without blocking the user. Polling is optimized with techniques such as AppState checks and randomized polling to reduce unnecessary network traffic and battery usage.
The result is an architecture that remains responsive under load by separating critical startup work from background services, parallelizing independent operations, and treating every non-essential task as asynchronous.


![Spaceship - The cheapest [.com] domain provider](https://cdn.sanity.io/images/3nfbwknm/production/10a304bba16276ff4fa5defef68d7f2500492fb2-600x301.png?fit=max&auto=format)
