Skip to main content

Command Palette

Search for a command to run...

How Instagram, WhatsApp, Uber & Netflix Would Be Built Today Using Expo Router

Updated
28 min read
How Instagram, WhatsApp, Uber & Netflix Would Be Built Today Using Expo Router

How Instagram, WhatsApp, Uber & Netflix Would Be Built Today Using Expo Router

Architecture isn't about the code you write. It's about the decisions you make before writing any code.

If you've ever opened a React Native project and stared at a components/ folder with 200 files in it, this article is for you. Today, we're going to think like the engineers who built Instagram, WhatsApp, Uber, and Netflix — and explore how you'd structure those apps if you were starting from scratch today, using Expo Router.

This isn't a UI clone tutorial. We're not building Instagram's Reels UI or Uber's map screen pixel-by-pixel. We're thinking about architecture — how to organize code, navigate between screens, handle state, sync data in real time, and scale without losing your mind.

Let's go.


1. Why Simple Folder Structures Fail at Scale

The "It Works on My Machine" Phase

Every React Native project starts simple. You have a screens/ folder, a components/ folder, maybe a utils/ folder, and a single App.tsx. It's clean, understandable, and everyone on the team (usually just you) knows where everything lives.

Then the app grows.

You add authentication, then a profile screen, then a feed, then notifications, then settings, then DMs. Suddenly your screens/ folder has 40 files, your components/ folder has 150+ components with names like FeedCard.tsx, FeedCardNew.tsx, and FeedCardV2Final.tsx. Your App.tsx has 600 lines of nested navigators. New team members spend two days just understanding the project before writing a single line of code.

This is not a theoretical problem. This is how most apps die.

What Goes Wrong

The root causes of a failing folder structure are almost always the same:

  • Feature logic is split across the codebase. The authentication screen, its API call, its state slice, its validation logic, and its UI components all live in different folders. To understand auth, you jump between 6 different directories.

  • No clear ownership. When something breaks, no one knows which folder "owns" the broken piece.

  • Shared components become a graveyard. A components/ folder that's shared by everyone is owned by no one. It accumulates dead code that nobody dares delete.

  • Routing becomes a monolith. A single navigation file managing 40+ screens becomes unmaintainable.

The Mental Shift: From Files to Features

The solution isn't a better folder name. It's a different way of thinking. Instead of organizing code by type (all screens together, all components together), you organize it by feature (everything related to messaging lives in the messaging/ module).

This is the shift from small-app thinking to production engineering thinking. And Expo Router makes this shift natural.


2. Why Architecture Matters in React Native Applications

Mobile Has Unique Constraints

Web apps can get away with messy architecture longer because the browser handles a lot — routing, caching, back navigation. In React Native, you own all of it.

  • Navigation is your responsibility. There's no browser URL bar. Every screen transition, deep link, and back press is code you write and maintain.

  • Performance is visible. A janky scroll, a slow startup, a flickering list — users feel it immediately. Architecture choices directly affect frame rate.

  • Offline behavior is expected. Mobile users expect apps to work in tunnels, on planes, and with bad connectivity. This requires deliberate caching and sync architecture.

  • Bundle size matters. A 50MB app has lower install rates than a 10MB app. Architecture decisions affect how code is split and loaded.

The Cost of Rearchitecting Later

Bad architecture in mobile is especially painful to fix because:

  1. Deep links are tied to your navigation structure. Changing routes breaks links shared by users.

  2. State management changes require rewriting screens, not just services.

  3. API layer changes ripple across every screen that touches data.

The engineers at Instagram, Uber, and Netflix didn't get their architecture right because they were smarter. They got it right because they invested in it early, before scale made changes expensive.


3. Folder Architecture Using Expo Router

How Expo Router Changes Everything

Expo Router brings file-based routing to React Native — the same mental model as Next.js. Your file structure is your navigation structure. This is a massive architectural win because:

  • Routes are self-documenting. You can read the folder structure and understand the app's navigation.

  • Deep linking works automatically. Every route is a URL.

  • Shared layouts are trivial. A _layout.tsx wraps all screens in its directory.

  • Code splitting is natural. Features map to folders, folders map to routes.

Production-Grade Expo Router Folder Structure

Here's how a production app at scale would be structured:

my-app/
├── app/                          # Expo Router root — file-based routes
│   ├── _layout.tsx               # Root layout (fonts, providers, auth guard)
│   ├── index.tsx                 # Entry redirect (to onboarding or home)
│   │
│   ├── (auth)/                   # Auth group — no tab bar
│   │   ├── _layout.tsx
│   │   ├── login.tsx
│   │   ├── register.tsx
│   │   └── forgot-password.tsx
│   │
│   ├── (tabs)/                   # Main app tab layout
│   │   ├── _layout.tsx           # Tab bar configuration
│   │   ├── index.tsx             # Home / Feed
│   │   ├── explore.tsx
│   │   ├── notifications.tsx
│   │   └── profile.tsx
│   │
│   ├── (modals)/                 # Full-screen modals
│   │   ├── _layout.tsx
│   │   ├── create-post.tsx
│   │   └── story-viewer.tsx
│   │
│   └── +not-found.tsx            # 404 screen
│
├── src/
│   ├── features/                 # Feature-based modules
│   │   ├── auth/
│   │   │   ├── components/
│   │   │   ├── hooks/
│   │   │   ├── services/
│   │   │   ├── store/
│   │   │   └── types.ts
│   │   ├── feed/
│   │   ├── messaging/
│   │   ├── profile/
│   │   ├── notifications/
│   │   └── player/               # e.g., for Netflix-style video
│   │
│   ├── shared/                   # Truly shared, stable code
│   │   ├── components/           # Button, Avatar, Card, etc.
│   │   ├── hooks/                # useDebounce, useThrottle, etc.
│   │   ├── utils/
│   │   └── constants/
│   │
│   ├── lib/                      # Third-party integrations
│   │   ├── api/                  # Axios/Fetch instance + interceptors
│   │   ├── socket/               # WebSocket/Socket.io client
│   │   ├── storage/              # MMKV / AsyncStorage wrapper
│   │   └── analytics/
│   │
│   └── store/                    # Global state (Zustand / Redux)
│       ├── auth.store.ts
│       ├── app.store.ts
│       └── index.ts
│
├── assets/
├── constants/
└── app.json

Understanding Route Groups

Expo Router's route groups (folders wrapped in parentheses) are the architectural backbone of a production app. They:

  • Group screens that share a layout without affecting the URL/path.

  • Allow you to have tab-bar screens and auth screens as completely separate layout trees.

  • Enable modals, drawers, and stacks to coexist cleanly.

(auth)/     → No tab bar, simple stack
(tabs)/     → Tab bar always visible
(modals)/   → Slides over current screen

This maps directly to how Instagram works: the feed, explore, and profile screens share a tab bar. The story viewer and DM composer are modals. The login screen has none of it.


4. Feature-Based Separation in Large Applications

One Module, One Responsibility

Feature-based architecture means each feature directory contains everything it needs:

src/features/messaging/
├── components/
│   ├── MessageBubble.tsx
│   ├── ChatHeader.tsx
│   └── TypingIndicator.tsx
├── hooks/
│   ├── useMessages.ts
│   ├── useSocket.ts
│   └── useTypingStatus.ts
├── services/
│   ├── messaging.api.ts         # API calls
│   └── messaging.socket.ts      # Socket event handlers
├── store/
│   └── messaging.store.ts       # Zustand/Redux slice
└── types.ts

When a new engineer joins and needs to work on chat, they go to src/features/messaging/. That's it. Everything they need is there.

Rules for Feature Modules

  1. Features can use shared/, but shared cannot import from features.

  2. Features should not directly import from other features. Cross-feature communication goes through the global store or events.

  3. API calls live in the feature's services/ folder, not in a global API folder.

  4. If a component is used in more than two features, it graduates to shared/components/.

WhatsApp Example: Feature Boundaries

In a WhatsApp-style app, the feature breakdown would look like:

Feature Owns
messaging/ Chats list, chat room, message bubbles, socket events
contacts/ Contact list, add contact, contact search
status/ Status feed, story-style updates
calls/ Audio/video call UI, WebRTC logic
profile/ User profile, settings
auth/ Login, OTP verification, session management

Each of these is a self-contained unit. The messaging/ feature talks to the contacts/ feature only through shared store or passed props — never through a direct import.


5. Navigation Architecture for Scalable Apps

The Three Navigation Primitives

Every mobile app, no matter how complex, is built on three navigation primitives:

  1. Stack — Push/pop screens. Used for drilling into detail views.

  2. Tabs — Side-by-side top-level sections. The bottom tab bar.

  3. Modal — Overlays the current context. Full-screen modals, bottom sheets.

Expo Router composes these naturally through nested layouts.

Root Layout (_layout.tsx)
├── Auth Stack  ← if not authenticated
│   ├── Login
│   ├── Register
│   └── Forgot Password
│
└── App (Tabs)  ← if authenticated
    ├── Tab: Feed
    │   └── Stack
    │       ├── Feed Home
    │       ├── Post Detail
    │       └── Comment Thread
    ├── Tab: Explore
    │   └── Stack
    │       ├── Explore Home
    │       └── Search Results
    ├── Tab: Messages
    │   └── Stack
    │       ├── Conversations List
    │       └── Chat Room
    └── Tab: Profile
        └── Stack
            ├── Profile Home
            └── Edit Profile

Modals (rendered above tabs)
├── Camera / Create Post
├── Story Viewer
└── Share Sheet

In Expo Router, this becomes:

// app/(tabs)/_layout.tsx
export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: "Home" }} />
      <Tabs.Screen name="explore" options={{ title: "Explore" }} />
      <Tabs.Screen name="messages" options={{ title: "Messages" }} />
      <Tabs.Screen name="profile" options={{ title: "Profile" }} />
    </Tabs>
  );
}

Instagram-Style Navigation

Instagram's navigation is a master class in this pattern:

  • Main tabs: Home, Search, Reels, Shop, Profile.

  • Tapping a post from the feed pushes a detail screen within the tab's stack.

  • Stories open as a modal — they slide up over the feed without changing your tab context.

  • The DM icon in the top-right opens another stack that sits outside the tab layout.

With Expo Router, the stories and DMs are modals. The feed detail is a stack push. No special navigation library configuration needed — the folder structure defines it all.


6. Authentication Flow Architecture

The Protected Route Pattern

The most critical navigation decision in any app is: how do you guard routes from unauthenticated users?

With Expo Router, the clean pattern is to handle this in the root layout:

// app/_layout.tsx
export default function RootLayout() {
  const { isAuthenticated, isLoading } = useAuthStore();

  if (isLoading) return <SplashScreen />;

  return (
    <Stack>
      {isAuthenticated ? (
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      ) : (
        <Stack.Screen name="(auth)" options={{ headerShown: false }} />
      )}
    </Stack>
  );
}

Authentication Flow with Protected Routes

App Launch
    │
    ▼
Check token in SecureStore / MMKV
    │
    ├── Token exists → Validate with server
    │       ├── Valid   → Navigate to (tabs)/
    │       └── Expired → Refresh token
    │               ├── Success → Navigate to (tabs)/
    │               └── Fail    → Navigate to (auth)/login
    │
    └── No token → Navigate to (auth)/login

Login Screen
    │
    ▼
POST /auth/login
    │
    ├── Success → Store access + refresh token → Navigate to (tabs)/
    └── Failure → Show error, stay on (auth)/login

Token Management Layer

Never scatter auth logic across screens. Centralize it:

src/lib/auth/
├── tokenStorage.ts     # Secure read/write of access & refresh tokens
├── tokenRefresh.ts     # Interceptor logic for expired tokens
└── authGuard.ts        # Route protection hook

The API client (Axios/Fetch) uses an interceptor that automatically attaches the access token to every request, and automatically refreshes it on 401 responses — without any screen knowing this is happening.

Biometric Authentication (Instagram/WhatsApp Pattern)

Large apps also implement app-lock via biometrics:

App returns to foreground
    │
    ▼
Check if biometric lock is enabled
    │
    ├── Enabled → Show biometric prompt (Face ID / Fingerprint)
    │       ├── Success → Unlock app
    │       └── Fail    → Show PIN fallback
    │
    └── Disabled → Resume directly

7. State Management Strategies for Large Apps

Don't Use One Solution for Everything

The most common mistake is reaching for Redux for everything. Production apps use multiple state management layers based on what kind of state it is:

State Type Solution Example
Server data (fetched) React Query / TanStack Query Feed posts, user profiles
Global UI state Zustand Theme, modal visibility, auth status
Real-time data Zustand + WebSocket Chat messages, live locations
Form state React Hook Form Login, post creation
URL/navigation state Expo Router Current screen, params
Persistent state MMKV / AsyncStorage Auth tokens, preferences

API Layer + State Management Flow

User Action (e.g., pulls to refresh feed)
    │
    ▼
React Query: useFeedQuery()
    │
    ├── Cache hit → Return stale data immediately, refetch in background
    │
    └── Cache miss / stale
            │
            ▼
        API Service Layer (src/lib/api/)
            │
            ├── Attach auth token (interceptor)
            ├── POST /feed?cursor=...
            └── Response
                    │
                    ├── Success → Update React Query cache → UI re-renders
                    └── Error   → Retry logic → Error boundary

Zustand for Global UI State

Zustand is the sweet spot for global UI and auth state — lightweight, no boilerplate, works perfectly with Expo Router:

// src/store/auth.store.ts
interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  setUser: (user: User) => void;
  clearAuth: () => void;
}

export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  isAuthenticated: false,
  setUser: (user) => set({ user, isAuthenticated: true }),
  clearAuth: () => set({ user: null, isAuthenticated: false }),
}));

Netflix Pattern: Hybrid State

Netflix uses a layered state strategy:

  • TanStack Query for content catalog (search results, category rows, show details — all server state with aggressive caching).

  • Zustand for playback state (current episode, playback position, quality setting).

  • MMKV for persisted watch progress (synced to server periodically, not on every second).

  • Context API for player UI state (overlay visibility, control bar) — scoped and doesn't need to be global.


8. API Handling and Networking Layers

The API Client Architecture

Never call fetch() directly in a component. Build a proper API layer:

src/lib/api/
├── client.ts          # Axios instance with base URL, timeout
├── interceptors.ts    # Auth token attach, refresh on 401, error logging
├── endpoints.ts       # Centralized endpoint constants
└── types.ts           # API response types
// src/lib/api/client.ts
const apiClient = axios.create({
  baseURL: process.env.EXPO_PUBLIC_API_URL,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' },
});

// Attach token on every request
apiClient.interceptors.request.use(async (config) => {
  const token = await tokenStorage.getAccessToken();
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// Handle 401 — refresh token silently
apiClient.interceptors.response.use(
  (res) => res,
  async (error) => {
    if (error.response?.status === 401) {
      await refreshAccessToken();
      return apiClient(error.config); // retry
    }
    return Promise.reject(error);
  }
);

Feature-Level Service Files

Each feature owns its API calls:

// src/features/feed/services/feed.api.ts
export const feedApi = {
  getFeed: (cursor?: string) =>
    apiClient.get<FeedResponse>('/feed', { params: { cursor } }),
  likePost: (postId: string) =>
    apiClient.post(`/posts/${postId}/like`),
  getComments: (postId: string) =>
    apiClient.get<CommentsResponse>(`/posts/${postId}/comments`),
};

Uber's Location Data Strategy

Uber doesn't hit a REST API for live location updates. They use a combination:

  • REST API for ride requests, pricing, driver info — standard request/response.

  • WebSocket for live driver location updates — continuous stream.

  • HTTP polling as fallback when WebSocket disconnects.

  • Client-side prediction — the map moves the pin smoothly between received coordinates using interpolation, so it doesn't jump every second.


9. Real-Time Systems

Chat Systems (WhatsApp Architecture)

WhatsApp's real-time chat is built on reliable message delivery with guaranteed order. In a React Native app, this translates to:

src/features/messaging/
├── services/
│   └── messaging.socket.ts     # All socket event logic
└── store/
    └── messaging.store.ts      # Local message state

The messaging socket layer:

// Simplified socket event flow
socket.on('message:receive', (message: Message) => {
  // 1. Optimistically add to local store
  messagingStore.addMessage(message);
  // 2. Persist to local DB (WatermelonDB / SQLite)
  localDb.messages.insert(message);
  // 3. Send delivery receipt
  socket.emit('message:delivered', { messageId: message.id });
});

Realtime Messaging Architecture Flow:

User types and sends message
        │
        ▼
Optimistic update → Add to local state immediately (show in UI)
        │
        ▼
Emit via WebSocket: socket.emit('message:send', payload)
        │
        ├── Server ACK received
        │       └── Update message status: sent → delivered
        │
        └── ACK timeout (5s)
                └── Queue for retry → Re-emit on reconnect

Live Updates (Instagram Feeds)

Instagram's feed doesn't use WebSockets for new posts — that would be too expensive at scale. Instead, they use:

  • Long polling or Server-Sent Events (SSE) for like counts and comment counts.

  • Background refresh every 60 seconds when the app is in focus.

  • Push notifications for activity (new follower, comment) — which deep link into Expo Router routes.

// Using TanStack Query for Instagram-style live feed data
const { data, refetch } = useQuery({
  queryKey: ['feed'],
  queryFn: feedApi.getFeed,
  refetchInterval: 60_000, // refetch every 60s
  staleTime: 30_000,       // treat data as fresh for 30s
});

Ride Tracking (Uber Architecture)

Uber's live tracking is one of the most technically demanding real-time features in any mobile app:

Driver's phone
    │ emits location every 4s via WebSocket
    ▼
Location service (backend)
    │ broadcasts to rider's WebSocket channel
    ▼
Rider's React Native app
    │
    ├── Receives coordinate {lat, lng, bearing}
    ├── Stores in Zustand: rideStore.updateDriverLocation()
    ├── Animates marker on map (interpolation over 4s)
    └── Updates ETA label

In React Native with Expo Router:

// src/features/ride/hooks/useDriverLocation.ts
export function useDriverLocation(rideId: string) {
  const updateDriverLocation = useRideStore((s) => s.updateDriverLocation);

  useEffect(() => {
    const channel = supabase
      .channel(`ride:${rideId}`)
      .on('broadcast', { event: 'location' }, ({ payload }) => {
        updateDriverLocation(payload);
      })
      .subscribe();

    return () => supabase.removeChannel(channel);
  }, [rideId]);
}

10. Offline-First Support and Caching

Why Offline Matters More on Mobile

A web user with no internet just sees an error. A mobile user with no internet expects the app to work — or at least show them their last known state. Instagram still shows your feed. WhatsApp still shows your conversations. Spotify still plays downloaded music.

Offline-first architecture means: assume the network is unreliable and design around it.

The Offline Cache Synchronization Flow

App opens / user navigates to screen
        │
        ▼
Check local cache (MMKV / WatermelonDB / SQLite)
        │
        ├── Cache hit → Render immediately from cache
        │       │
        │       └── If online → Fetch fresh data in background
        │               └── On response → Diff and update cache → Re-render if changed
        │
        └── Cache miss + offline → Show empty state with "No connection" UI
                       + offline → Queue actions (likes, messages) for retry on reconnect

Tools for Offline-First in React Native

Layer Tool Use Case
Key-value cache MMKV User preferences, auth tokens, last-seen timestamps
Structured local DB WatermelonDB Chat messages, draft posts, feed cache
Query cache TanStack Query API response caching with stale-while-revalidate
Optimistic updates TanStack Query Likes, follows — update UI before server confirms
Background sync Expo Background Fetch Sync queued actions when connectivity returns

WhatsApp's Offline Message Queue

// When offline, queue outgoing messages
async function sendMessage(chatId: string, content: string) {
  const message = createOptimisticMessage(content); // status: 'queued'
  messagingStore.addMessage(message);               // show in UI

  if (!networkStore.isOnline) {
    await messageQueue.enqueue({ chatId, message }); // persist to local DB
    return;
  }

  try {
    await messagingApi.sendMessage(chatId, content);
    messagingStore.updateStatus(message.id, 'sent');
  } catch {
    messagingStore.updateStatus(message.id, 'failed');
  }
}

// Drain the queue on reconnect
networkStore.onReconnect(async () => {
  const queued = await messageQueue.getAll();
  for (const item of queued) {
    await sendMessage(item.chatId, item.message.content);
    await messageQueue.remove(item.id);
  }
});

11. App Startup Optimization Techniques

The Problem: Slow Cold Start

Cold start time — the time from tapping the app icon to seeing your first interactive screen — directly impacts retention. Every second of loading loses users. Facebook's internal studies have shown that every 100ms of startup latency reduces engagement meaningfully.

App Startup Lifecycle Optimization

App process starts
    │
    ▼
[Phase 1: Critical bootstrap — must be fast]
    ├── Load fonts (preload in _layout.tsx with expo-font)
    ├── Read auth token from MMKV (synchronous, no async)
    └── Determine initial route (auth or tabs)
    │
    ▼
[Phase 2: Render first screen — show something immediately]
    ├── Show splash screen or skeleton
    └── Start background tasks (non-blocking):
            ├── Validate token with server
            ├── Prefetch critical data (e.g., first page of feed)
            └── Initialize analytics, crash reporting
    │
    ▼
[Phase 3: Hydration — fill in the real content]
    ├── Replace skeletons with real data
    ├── Initialize WebSocket connection
    └── Schedule background sync

Key Optimization Techniques

1. Use MMKV instead of AsyncStorage for token reads MMKV is synchronous and 10x faster than AsyncStorage. Reading the auth token on startup becomes instantaneous.

2. Defer non-critical initialization Don't initialize analytics, Sentry, or push notifications in the root layout. Initialize them after the first screen renders.

3. Prefetch above-the-fold data While showing the splash/skeleton, start fetching the feed. By the time the user sees the feed, data is already loading.

4. Use expo-splash-screen to control dismissal Don't dismiss the splash screen until fonts are loaded and the auth check is complete. This prevents a flash of unstyled content.

// app/_layout.tsx
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [fontsLoaded] = useFonts({ ... });
  const { isAuthChecked } = useAuthStore();

  useEffect(() => {
    if (fontsLoaded && isAuthChecked) {
      SplashScreen.hideAsync();
    }
  }, [fontsLoaded, isAuthChecked]);
}

5. Lazy-load heavy screens Netflix's video player, Uber's map, Instagram's camera — these shouldn't be loaded at startup. Use React's lazy() and Suspense to load them on demand.


12. Performance Considerations in Production Apps

The Performance Killers

Most React Native performance issues come from the same handful of mistakes:

Problem Impact Fix
Re-rendering entire lists Jank while scrolling Use FlashList instead of FlatList
Heavy components on main thread Dropped frames Move to worklets with react-native-reanimated
Inline object/function props Constant re-renders useCallback, useMemo, memo()
Synchronous storage reads Blocked JS thread MMKV (sync), avoid AsyncStorage in hot paths
Large images not cached Slow feed scroll expo-image with memory + disk cache
Uncontrolled re-renders from global store UI lag Use selectors, subscribe to minimal state

Instagram's Feed Performance

Instagram's feed scrolls at 60fps with high-res images and videos. The key techniques:

  • FlashList (by Shopify) instead of FlatList — recycles cell components more efficiently.

  • Progressive image loading — show a blurred thumbnail, then full image.

  • expo-image — built-in disk and memory caching, reduces re-downloads.

  • Virtualization — only the ~5 items visible + ~10 off-screen are rendered.

  • Video pause on scroll — videos pause when they leave the viewport, freeing GPU resources.

Netflix's Video Playback Architecture

Netflix has the most demanding performance requirements:

  • Adaptive bitrate streaming (HLS/DASH) — quality adjusts to network speed automatically.

  • Pre-buffering — the next episode starts buffering 30s before the current one ends.

  • Background download — offline downloads happen in the background via expo-file-system + react-native-background-downloader.

  • Playback state isolation — the player's Zustand store is separate from the app store. Player state changes don't cause the rest of the app to re-render.


13. Shared Layouts and Nested Routing in Expo Router

The Power of _layout.tsx

Every folder in your app/ directory can have a _layout.tsx that wraps all screens inside it. This is how you share headers, tab bars, sidebars, and context providers without repeating them.

app/
├── _layout.tsx              ← Wraps EVERYTHING (fonts, providers, auth guard)
├── (tabs)/
│   ├── _layout.tsx          ← Wraps all tabs (tab bar itself)
│   └── (feed)/
│       ├── _layout.tsx      ← Wraps feed screens (shared header context)
│       ├── index.tsx        ← /feed
│       └── [postId].tsx     ← /feed/123

Dynamic Routes for Content Apps

Dynamic routes ([id]) are essential for content apps:

app/(tabs)/(feed)/
├── index.tsx              → /feed
├── [postId].tsx           → /feed/abc123
└── [postId]/
    └── comments.tsx       → /feed/abc123/comments

This means deep links like myapp://feed/abc123 work automatically. No manual deep link handling. Expo Router resolves it to the right screen.

Nested Navigation: Instagram DMs Example

Instagram's DM flow sits outside the main tab structure — it's like a second app that slides over:

app/
├── (tabs)/                 ← Main tab navigation
│   ├── index.tsx           ← Feed
│   └── profile.tsx         ← Profile
│
└── (messages)/             ← Separate stack, slides over tabs
    ├── _layout.tsx         ← Stack navigator, no tab bar
    ├── index.tsx           ← Conversations list
    └── [chatId].tsx        ← Individual chat room

When a user taps the DM icon, they're pushed into the (messages) stack, which renders over the tabs but preserves the tab bar state underneath.


14. Scalability Challenges and How Each App Solves Them

Instagram: Feeds and Media at Scale

Challenge: A user follows 1,000 accounts. Computing a personalized feed of recent posts from all of them in real time is expensive. Serving full-resolution images to 2 billion users is expensive.

Architecture decisions:

  • Fan-out on write vs. fan-out on read: Instagram pre-computes feeds for most users (fan-out on write) but computes feeds on-demand (fan-out on read) for users who follow celebrities with 100M+ followers.

  • CDN for media: Every image goes through a CDN. The app requests the image at the exact pixel dimensions needed, not the original.

  • Progressive JPEG + lazy loading: Feed items below the fold are not loaded until the user scrolls near them.

  • Expo Router impact: The feed screen uses a dynamic route for post details ([postId].tsx). Stories use a modal layout to avoid breaking feed scroll position.

WhatsApp: Realtime Messaging at Scale

Challenge: 100 billion messages per day. Message ordering must be consistent. Messages must arrive even if the receiver is offline for 30 days.

Architecture decisions:

  • XMPP-derived protocol: Messages are not stored on servers by default — end-to-end encrypted and pushed to device.

  • Message queue for offline delivery: Messages are held on server until the device comes online, then delivered in order.

  • Local database first: All messages are written to local SQLite (WatermelonDB in React Native equivalent) first. The server is the source of truth for delivery but the device DB is the source of truth for display.

  • Expo Router impact: Chat rooms are dynamic routes ([chatId].tsx). Typing indicators and online status come from a WebSocket layer, completely separate from the API layer.

Uber: Maps and Live Location at Scale

Challenge: Tracking millions of active drivers and riders simultaneously. Matching the nearest available driver in real time. Routing that updates as traffic changes.

Architecture decisions:

  • Geohashing: The map is divided into grid cells. Uber only tracks drivers in cells near the rider's cell — not all drivers everywhere.

  • Location updates decoupled from map rendering: The app receives location updates every 4 seconds but animates the map pin continuously using interpolation.

  • Separate service for each phase: Ride request, driver matching, active ride tracking, and payment are handled by separate backend microservices. The app consumes them through a unified API gateway.

  • Expo Router impact: The ride tracking screen is a modal that slides over the home map. It subscribes to a Supabase/Socket.io channel scoped to the active rideId — which comes from the route param.

Netflix: Heavy Content Delivery at Scale

Challenge: Streaming video to 300M subscribers simultaneously. Recommending content that keeps users watching. Handling content libraries with thousands of titles across dozens of markets.

Architecture decisions:

  • Adaptive bitrate streaming: The player automatically switches between 240p and 4K based on current bandwidth.

  • Open Connect (CDN): Netflix operates its own CDN appliances placed inside ISPs. Popular content is cached at the ISP level, dramatically reducing streaming latency.

  • Recommendation as a separate layer: The recommendation engine runs server-side. The app just receives a list of contentId arrays for each row ("Continue Watching", "Top Picks for You"). It doesn't know the algorithm.

  • Expo Router impact: Content is browsed via tabs (Home, Search, Downloads, Profile). The player is a full-screen modal with hidden navigation chrome. The contentId comes from a dynamic route ([contentId].tsx), making deep links from push notifications trivially simple.


15. Tradeoffs and Architectural Decisions at Scale

When to Use React Query vs. Zustand vs. Context

Choose When
React Query Data that comes from a server, needs caching, pagination, or background refresh
Zustand UI state that's global but not server-derived (auth status, theme, modal open/closed)
Context API State scoped to a subtree (player state, form state, a specific feature)
Local useState State that's entirely local to a single component

Tradeoffs Every Team Faces

Expo Router vs. React Navigation directly

Expo Router adds file-based conventions but requires your project to follow its folder structure. React Navigation gives more control at the cost of more boilerplate. For new projects, Expo Router wins. For migrating existing projects, the cost is higher.

TanStack Query vs. RTK Query

Both are excellent. TanStack Query has a smaller API surface and is framework-agnostic. RTK Query integrates tightly with Redux. If your team already uses Redux, RTK Query is less friction. If starting fresh, TanStack Query is simpler.

WatermelonDB vs. SQLite directly vs. no local DB

WatermelonDB is the right choice for apps with complex relational data (messaging, social feeds). SQLite directly is lower-level but works. Most apps don't need a local DB — MMKV + TanStack Query cache covers 80% of use cases.

Monorepo vs. separate repos

At Uber and Meta scale, different teams own different parts of the app. A monorepo (Nx or Turborepo) allows shared code while keeping team ownership clear. For solo developers and small teams, a single repo is fine.

The Most Important Decision: Invest in Architecture Early

Here's the honest truth: you will not build Instagram, WhatsApp, Uber, or Netflix. But you will build something that grows beyond what you planned for. The folder structure you choose in week one will still be the folder structure you're arguing about in year three.

Feature-based architecture with Expo Router's file-based routing gives you a system that scales with the team and the product. Not because it's perfect, but because its conventions force clarity — every engineer knows where to look, every new feature knows where to live.

That clarity, at scale, is everything.


Summary: Key Architectural Principles

Principle Why It Matters
File-based routing with Expo Router Routes are self-documenting; deep links are free
Feature-based folder structure Clear ownership; no spaghetti imports
Separate state layers (Query + Zustand + MMKV) Right tool for right state type
Centralized API client with interceptors Auth and error handling in one place
Offline-first with optimistic updates Resilient UX on unreliable networks
Lazy-load heavy features Fast startup; load on demand
Real-time via WebSocket, REST for everything else Don't over-engineer; sockets only where needed
Performance: FlashList + expo-image + Reanimated 60fps even with complex UIs

Conclusion

The apps we admire didn't start with perfect architecture. They evolved. Engineers at Instagram and WhatsApp have written public post-mortems about the painful rewrites they had to do when their early structure couldn't scale. You have the advantage of learning from those rewrites.

Expo Router gives you a routing system that grows with you. Feature-based architecture gives you a codebase where every file has a home. A layered state strategy gives you the right tool for every type of data.

You don't need to build Instagram. You need to build your app in a way that won't embarrass you in two years. Start with structure. The features follow naturally.