“Why Does My App Show a Blank Screen After Lunch?”

You built a beautiful Expo app in Cursor. Claude Code generated the auth flow, the dashboard, the settings screen. You shipped it. Users downloaded it. Then the DMs started:

  • “Why does the app show a blank screen when I open it after lunch?”
  • “Why do I have to log in again every morning?”
  • “Why did my data disappear when I switched back from Instagram?”

You didn’t build a broken app. You built a web app mentality inside a native app container. Browsers don’t have app lifecycle. Mobile apps do. When a user presses the home button, your app doesn’t die — it sleeps. When they tap your icon six hours later, it wakes up. And everything that was true six hours ago is now a lie.

The Resume Problem

When a user returns after hours away, you have decisions to make. Is the session still valid? Is the data fresh enough? Most vibe-coded apps answer these ad-hoc, per-screen, with inconsistent results. One screen re-fetches. Another shows stale data. A third crashes because it assumes the session is still there.

The fix: derive a resumeStatus from time elapsed since the user was last active, and let the whole app react to it:

// src/context/app-lifecycle-provider.tsx

import { AppState } from 'react-native'

export type AppResumeStatus =
  'fresh' | 'resumed' | 'stale' | 'expired'

export function getResumeStatus(
  lastActiveAt: number,
  now: number,
  staleMs: number = 5 * 60 * 1000,       // 5 minutes
  expireMs: number = 24 * 60 * 60 * 1000  // 24 hours
): AppResumeStatus {
  const idleMs = now - lastActiveAt
  if (idleMs >= expireMs) return 'expired'
  if (idleMs >= staleMs) return 'stale'
  return lastActiveAt === 0 ? 'fresh' : 'resumed'
}

Four states, each with clear meaning:

  • fresh: First launch. No previous session.
  • resumed: Back within 5 minutes. Data is probably fine.
  • stale: Gone 5+ minutes. Time to re-fetch key data.
  • expired: Gone 24+ hours. Treat like a fresh launch.

The Lifecycle Provider

Subscribe to AppState once. Expose the derived status everywhere:

export function AppLifecycleProvider({
  children,
}: PropsWithChildren) {
  const { storage } = useServices()
  const [state, setState] = useState({
    appState: AppState.currentState,
    lastActiveAt: 0,
    resumeStatus: 'fresh' as AppResumeStatus,
    ready: false,
  })

  useEffect(() => {
    const sub = AppState.addEventListener(
      'change',
      (nextAppState) => {
        if (nextAppState === 'active') {
          const resumeStatus = getResumeStatus(
            state.lastActiveAt,
            Date.now()
          )
          setState((s) => ({
            ...s,
            appState: nextAppState,
            resumeStatus,
          }))
        } else if (nextAppState === 'background') {
          storage.fastStorage.set('lastActiveAt', Date.now())
          setState((s) => ({
            ...s,
            appState: nextAppState,
            lastActiveAt: Date.now(),
          }))
        }
      }
    )

    storage.fastStorage
      .get<number>('lastActiveAt')
      .then((ts) => {
        setState((s) => ({
          ...s,
          lastActiveAt: ts ?? 0,
          resumeStatus: getResumeStatus(ts ?? 0, Date.now()),
          ready: true,
        }))
      })

    return sub.remove
  }, [storage])

  return (
    <LifecycleContext.Provider value={state}>
      {children}
    </LifecycleContext.Provider>
  )
}

The key insight: auth, updates, data refresh — every provider that cares about resumption reads resumeStatus from this one context. No screen writes its own AppState.addEventListener. No feature guesses how long the app was backgrounded.

Route Guards Wait for Both

Your route guards should wait for both auth restoration and lifecycle readiness before rendering:

// app/_layout.tsx

function RootNavigator() {
  const { session, loading } = useAuth()
  const { ready: lifecycleReady } = useAppLifecycle()

  if (loading || !lifecycleReady) return null

  return (
    <Stack>
      <Stack.Protected guard={!session}>
        <Stack.Screen name='login' />
      </Stack.Protected>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name='(app)' />
      </Stack.Protected>
    </Stack>
  )
}

Web developers don’t think about this because browsers don’t have an app lifecycle. Mobile developers don’t have that luxury. The Autotomy Expo Starter Pack handles lifecycle management out of the box so your vibe-coded app doesn’t break when users come back.