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

> Architecture is not about how things look. It's about how they survive.

* * *

## Introduction: The Day Your Folder Structure Betrays You

Every React Native project starts the same way. You create `components/`, `screens/`, `utils/`, maybe `hooks/` if you're feeling fancy. It works. You ship. Life is good.

Then the product grows. A new feature. Then another. A junior joins the team. Then two more. Three months later, `screens/` has 47 files and no one remembers what `HomeScreen2Final_v3.tsx` does.

This isn't a cleanliness problem. It's an architecture problem — and the difference between a startup mobile app and a production system used by hundreds of millions of people comes down entirely to how decisions like these were made early on.

In this article, we're not going to clone Instagram or Uber. We're going to think like the engineers who built them — and explore how **Expo Router** gives us the primitives to architect mobile applications that actually scale.

* * *

## Why Simple Folder Structures Fail at Scale

Let's be honest about what most tutorials teach:

```plaintext
/src
  /components
    Button.tsx
    Card.tsx
    Avatar.tsx
  /screens
    HomeScreen.tsx
    ProfileScreen.tsx
    SettingsScreen.tsx
  /utils
    formatDate.ts
    api.ts
  /hooks
    useAuth.ts
```

This is perfectly fine for a to-do app. But now imagine you're Instagram and `HomeScreen.tsx` needs:

*   Feed rendering with infinite scroll
    
*   Stories bar with circular avatars
    
*   Reels entry point
    
*   Notification badge
    
*   Search shortcut
    
*   Live activity indicators
    

Suddenly your "screen" file is 800 lines. Your `api.ts` is 3,000 lines. Your `components/` folder has 90 files with zero context about which feature they belong to.

**The failure modes are predictable:**

| Problem | Root Cause |
| --- | --- |
| Merge conflicts on every sprint | Everyone touches the same files |
| "I don't know what breaks if I change this" | No feature boundaries |
| Slow CI builds | Imports chain across everything |
| Impossible onboarding | No local coherence |
| Fear of refactoring | No encapsulation |

Scale punishes shared global structure. What you need instead is **feature locality** — the idea that everything related to a feature lives together, so the feature can be understood, tested, and replaced in isolation.

* * *

## The Production Mindset Shift

Before diving into Expo Router specifics, internalize this shift:

| Small App Thinking | Production Engineering Thinking |
| --- | --- |
| "Where do files go?" | "Who owns this code?" |
| Flat folder structure | Feature-based domain separation |
| One global state store | Scoped state per domain |
| Fetch from any component | Centralized, typed API layer |
| Navigate anywhere | Defined navigation contracts |
| Bundle everything | Lazy-load aggressively |

Production apps at Instagram's scale have teams of 8–12 engineers who own a single feature vertical (e.g., "Reels" or "Stories"). Their code must be completely isolated from other teams' code. This is not optional — it's what keeps 600+ engineers from destroying each other's work.

* * *

## Expo Router: File-Based Navigation at Scale

Expo Router brings the Next.js mental model to React Native — your file system **is** your navigation tree. This is a profound shift from React Navigation's imperative stack definitions.

```plaintext
/app
  _layout.tsx          ← Root layout (fonts, providers, splash)
  index.tsx            ← Entry point / redirect
  (auth)/
    _layout.tsx        ← Auth group layout (no tabs)
    login.tsx
    signup.tsx
    forgot-password.tsx
  (app)/
    _layout.tsx        ← Tab layout (authenticated shell)
    (feed)/
      _layout.tsx
      index.tsx        ← Feed home
      [postId].tsx     ← Dynamic post detail
    (search)/
      index.tsx
      results.tsx
    (reels)/
      index.tsx
    (notifications)/
      index.tsx
    (profile)/
      _layout.tsx
      index.tsx
      [userId].tsx     ← Public profile
      edit.tsx
  +not-found.tsx
```

Three things to notice here:

1.  **Route groups** `(auth)` and `(app)` let you apply different layouts to different navigation contexts without polluting the URL/path
    
2.  **Dynamic routes** `[postId].tsx` are typed and composable
    
3.  **Layout nesting** means your tab bar, header, and providers are applied declaratively — no more wrapping 12 providers around your navigator
    

### Authentication Flow with Protected Routes

```tsx
// app/(app)/_layout.tsx
import { Redirect } from 'expo-router';
import { useAuthStore } from '@/features/auth/store';

export default function AppLayout() {
  const { isAuthenticated, isLoading } = useAuthStore();

  if (isLoading) return <SplashScreen />;
  if (!isAuthenticated) return <Redirect href="/login" />;

  return <TabLayout />;
}
```

```tsx
// app/(auth)/_layout.tsx
import { Redirect } from 'expo-router';
import { useAuthStore } from '@/features/auth/store';

export default function AuthLayout() {
  const { isAuthenticated } = useAuthStore();

  // Already logged in? Kick them to the app
  if (isAuthenticated) return <Redirect href="/(app)/(feed)" />;

  return <Stack />;
}
```

This pattern creates a **hard navigation boundary** between authenticated and unauthenticated states. No manual route guards. No runtime navigation calls in `useEffect`. The file system enforces it.

* * *

## Feature-Based Architecture: The Real Structure

The `/app` folder only defines routes. The actual product logic lives in `/features`. This separation is critical.

```plaintext
/src
  /features
    /auth
      components/
        LoginForm.tsx
        OAuthButtons.tsx
      hooks/
        useLogin.ts
        useOAuthFlow.ts
      store/
        authStore.ts       ← Zustand slice
      api/
        authApi.ts
      types/
        auth.types.ts
      index.ts             ← Public barrel export

    /feed
      components/
        FeedList.tsx
        PostCard.tsx
        StoryBar.tsx
        StoriesReel.tsx
      hooks/
        useFeed.ts
        useInfiniteScroll.ts
        useLikePost.ts
      store/
        feedStore.ts
      api/
        feedApi.ts
      types/
        feed.types.ts
      index.ts

    /messaging          ← WhatsApp-like
    /maps               ← Uber-like
    /player             ← Netflix-like
    /notifications
    /search
    /profile

  /shared
    /components
      /ui               ← Design system primitives
        Button.tsx
        Avatar.tsx
        Badge.tsx
        Sheet.tsx
      /layout
        SafeArea.tsx
        KeyboardAware.tsx
    /hooks
      useDebounce.ts
      useAppState.ts
      usePrevious.ts
    /lib
      queryClient.ts    ← TanStack Query setup
      socket.ts         ← Socket.io singleton
      storage.ts        ← MMKV setup
      analytics.ts
    /types
      global.d.ts
      api.types.ts

  /constants
    routes.ts
    config.ts
    endpoints.ts
```

The key rule: **a feature can import from** `/shared`**, but never from another feature directly.** If two features need to share something, it moves to `/shared`. This creates explicit dependency management without a monorepo toolchain.

* * *

## Navigation Architecture for Scalable Apps

### The Navigation Hierarchy

```plaintext
Root Stack
├── (auth) Group
│   ├── /login
│   ├── /signup
│   └── /forgot-password
│
└── (app) Group — Tab Navigator
    ├── Tab: (feed)
    │   ├── /              ← Feed index
    │   ├── /[postId]      ← Post detail (pushes onto stack)
    │   └── /comments/[postId]
    │
    ├── Tab: (search)
    │   ├── /              ← Search home
    │   └── /results       ← Results with params
    │
    ├── Tab: (reels)       ← Full-screen vertical scroll
    │
    ├── Tab: (notifications)
    │
    └── Tab: (profile)
        ├── /              ← Own profile
        ├── /edit
        └── /[userId]      ← Others' profiles
```

### Shared Layouts and Nested Routing

Expo Router's nested layouts are what make complex UIs manageable:

```tsx
// app/(app)/_layout.tsx — The app shell with tabs
export default function AppTabsLayout() {
  return (
    <Tabs
      screenOptions={{ headerShown: false }}
      tabBar={(props) => <CustomTabBar {...props} />}
    >
      <Tabs.Screen name="(feed)" options={{ title: 'Home' }} />
      <Tabs.Screen name="(search)" options={{ title: 'Search' }} />
      <Tabs.Screen name="(reels)" options={{ title: 'Reels' }} />
      <Tabs.Screen name="(notifications)" options={{ title: 'Activity' }} />
      <Tabs.Screen name="(profile)" options={{ title: 'Profile' }} />
    </Tabs>
  );
}
```

```tsx
// app/(app)/(feed)/_layout.tsx — Feed's own stack
export default function FeedLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ headerShown: false }} />
      <Stack.Screen name="[postId]" options={{ presentation: 'card' }} />
      <Stack.Screen
        name="comments/[postId]"
        options={{ presentation: 'modal' }}
      />
    </Stack>
  );
}
```

Each tab maintains its own navigation stack, just like the actual Instagram app — going deep into a post and back doesn't reset your tab position.

* * *

## API Handling and Networking Layer

Every production app at scale has an API layer that is completely decoupled from UI components. Components never call `fetch()`. Ever.

### The Three-Layer API Architecture

```plaintext
Component → Hook (TanStack Query) → API Module → HTTP Client
```

```ts
// src/shared/lib/apiClient.ts
import axios from 'axios';
import { tokenStore } from '@/features/auth/store';

export const apiClient = axios.create({
  baseURL: process.env.EXPO_PUBLIC_API_URL,
  timeout: 10_000,
});

// Request interceptor — attach auth token
apiClient.interceptors.request.use((config) => {
  const token = tokenStore.getState().accessToken;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// Response interceptor — handle 401, token refresh
apiClient.interceptors.response.use(
  (res) => res,
  async (error) => {
    if (error.response?.status === 401) {
      await tokenStore.getState().refreshTokens();
      return apiClient.request(error.config); // Retry
    }
    return Promise.reject(error);
  }
);
```

```ts
// src/features/feed/api/feedApi.ts
import { apiClient } from '@/shared/lib/apiClient';
import type { FeedPost, PaginatedResponse } from '@/shared/types';

export const feedApi = {
  getFeed: (cursor?: string) =>
    apiClient.get<PaginatedResponse<FeedPost>>('/feed', {
      params: { cursor, limit: 12 },
    }),

  likePost: (postId: string) =>
    apiClient.post(`/posts/${postId}/like`),

  getPost: (postId: string) =>
    apiClient.get<FeedPost>(`/posts/${postId}`),
};
```

```ts
// src/features/feed/hooks/useFeed.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { feedApi } from '../api/feedApi';

export function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => feedApi.getFeed(pageParam),
    getNextPageParam: (lastPage) => lastPage.data.nextCursor,
    staleTime: 1000 * 30, // 30 seconds
  });
}
```

Components stay clean. They use hooks. Hooks use the API layer. The API layer uses the HTTP client. Every concern has exactly one home.

* * *

## State Management Strategies

There is no single right answer here, but there is a clear pattern used by production teams:

| State Type | Tool | Example |
| --- | --- | --- |
| Server state (async) | TanStack Query | Feed posts, user profiles |
| UI state (local) | `useState` / `useReducer` | Modal open/close, form input |
| Shared client state | Zustand | Auth session, theme, cart |
| Persistent state | MMKV + Zustand persist | Token, user preferences |
| Navigation state | Expo Router | Current route, params |

The mistake most teams make: using a global store for everything. Server state (your posts, your messages) belongs in a cache manager like TanStack Query, not in Redux or Zustand. It knows about staling, refetching, deduplication, and background updates. Your Zustand store does not.

```ts
// src/features/auth/store/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { storage } from '@/shared/lib/storage'; // MMKV

interface AuthState {
  accessToken: string | null;
  user: User | null;
  isAuthenticated: boolean;
  setTokens: (access: string) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      accessToken: null,
      user: null,
      isAuthenticated: false,
      setTokens: (access) => set({ accessToken: access, isAuthenticated: true }),
      logout: () => set({ accessToken: null, user: null, isAuthenticated: false }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => storage), // MMKV adapter
    }
  )
);
```

* * *

## Realtime Systems

This is where the four apps diverge most dramatically.

### WhatsApp: Chat Architecture

WhatsApp's realtime model is built on three principles: **local optimism, server reconciliation, and delivery receipts.**

```ts
// src/shared/lib/socket.ts
import { io, Socket } from 'socket.io-client';

let socket: Socket | null = null;

export function getSocket(): Socket {
  if (!socket) {
    socket = io(process.env.EXPO_PUBLIC_WS_URL!, {
      auth: { token: tokenStore.getState().accessToken },
      transports: ['websocket'],
      reconnectionAttempts: Infinity,
      reconnectionDelay: 1000,
    });
  }
  return socket;
}
```

```ts
// src/features/messaging/hooks/useMessages.ts
export function useMessages(conversationId: string) {
  const queryClient = useQueryClient();
  const socket = getSocket();

  useEffect(() => {
    socket.emit('join:conversation', conversationId);

    socket.on('message:new', (message: Message) => {
      // Optimistically update the cache
      queryClient.setQueryData(
        ['messages', conversationId],
        (old: Message[]) => [...(old ?? []), message]
      );
    });

    return () => {
      socket.emit('leave:conversation', conversationId);
      socket.off('message:new');
    };
  }, [conversationId]);

  return useInfiniteQuery({
    queryKey: ['messages', conversationId],
    queryFn: ({ pageParam }) => messagingApi.getMessages(conversationId, pageParam),
    getNextPageParam: (first) => first.data.prevCursor,
  });
}

export function useSendMessage(conversationId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: messagingApi.sendMessage,
    onMutate: async (newMessage) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['messages', conversationId] });

      // Optimistic update with temporary ID
      const optimistic = { ...newMessage, id: `temp-${Date.now()}`, status: 'sending' };
      queryClient.setQueryData(['messages', conversationId], (old: Message[]) =>
        [...(old ?? []), optimistic]
      );

      return { optimistic };
    },
    onSuccess: (real, _, context) => {
      // Replace optimistic message with real one
      queryClient.setQueryData(['messages', conversationId], (old: Message[]) =>
        old.map((m) => (m.id === context?.optimistic.id ? real.data : m))
      );
    },
    onError: (_, __, context) => {
      // Mark as failed
      queryClient.setQueryData(['messages', conversationId], (old: Message[]) =>
        old.map((m) =>
          m.id === context?.optimistic.id ? { ...m, status: 'failed' } : m
        )
      );
    },
  });
}
```

**The key insight:** messages are sent optimistically. Your UI shows the message immediately. If the server fails, you mark it as failed and let the user retry. This is why WhatsApp feels instant even on poor connections.

### Uber: Maps and Live Location

Uber's frontend challenge is rendering a live map with moving driver locations efficiently. The danger is re-rendering the entire map on every location update.

```ts
// src/features/maps/hooks/useDriverLocation.ts
export function useDriverLocation(rideId: string) {
  const locationRef = useRef<DriverLocation | null>(null);
  const socket = getSocket();

  useEffect(() => {
    socket.emit('ride:watch', rideId);

    socket.on('driver:location', (location: DriverLocation) => {
      locationRef.current = location;
      // Update map marker directly via ref — no re-render
      mapRef.current?.animateToRegion({
        latitude: location.lat,
        longitude: location.lng,
        latitudeDelta: 0.01,
        longitudeDelta: 0.01,
      });
    });

    return () => socket.emit('ride:unwatch', rideId);
  }, [rideId]);

  return locationRef;
}
```

The critical pattern: **don't put live coordinates in React state.** Use a ref and update the map imperatively. State updates trigger re-renders. At 10 location updates per second, that's 10 full component re-renders — which will visibly stutter on mid-range Android devices.

### Netflix: Heavy Content Delivery

Netflix's mobile challenge isn't realtime — it's content delivery and startup performance. Their entire architecture is built around making content visible within 100ms of opening the app.

```ts
// src/features/player/hooks/useVideoPreload.ts
import { Video, ResizeMode } from 'expo-av';

export function useVideoPreload(uri: string) {
  const videoRef = useRef<Video>(null);

  useEffect(() => {
    // Preload 30 seconds of video while user is browsing
    Video.setAudioModeAsync({ playsInSilentModeIOS: true });
    videoRef.current?.loadAsync({ uri }, {}, false);

    return () => videoRef.current?.unloadAsync();
  }, [uri]);

  return videoRef;
}
```

Netflix also implements **adaptive bitrate streaming (HLS/DASH)** — the player requests lower-quality segments on poor connections and switches up transparently. On mobile, this is handled by the native player layer, not React Native JS.

* * *

## Offline-First Support and Caching

### The Instagram Example

Instagram works offline. You can scroll your cached feed, double-tap posts, and your likes queue up locally. When you reconnect, they sync.

```ts
// src/shared/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { storage } from './storage';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,   // 5 minutes
      gcTime: 1000 * 60 * 60 * 24, // 24 hours in cache
      retry: 3,
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
    },
  },
});

const persister = createAsyncStoragePersister({
  storage: {
    getItem: (key) => storage.getString(key) ?? null,
    setItem: (key, value) => storage.set(key, value),
    removeItem: (key) => storage.delete(key),
  },
});

persistQueryClient({ queryClient, persister });
```

```ts
// src/features/feed/hooks/useLikePost.ts — Offline-optimistic like
export function useLikePost() {
  const { isConnected } = useNetInfo();
  const pendingQueue = usePendingActionsStore();

  return useMutation({
    mutationFn: feedApi.likePost,
    onMutate: async (postId) => {
      // Optimistic UI update always
      queryClient.setQueryData(['post', postId], (old: Post) => ({
        ...old,
        liked: true,
        likeCount: old.likeCount + 1,
      }));
    },
    onError: (_, postId) => {
      if (!isConnected) {
        // Queue for later — don't revert
        pendingQueue.enqueue({ type: 'like', postId });
      } else {
        // Actually failed — revert
        queryClient.invalidateQueries({ queryKey: ['post', postId] });
      }
    },
  });
}
```

When the user comes back online, you drain the pending queue in order. Instagram's likes feel instant because they never wait for the network.

* * *

## App Startup Optimization

The first 2 seconds of your app's launch determine whether users trust it. Every millisecond of white screen is brand damage.

### The Startup Lifecycle

```plaintext
Native Boot
  └─ JS Bundle Evaluation  ← Minimize this
       └─ Root Layout Mount
            ├─ Font Loading (preloaded natively)
            ├─ Token Check (MMKV sync — 0ms)
            ├─ Auth Gate Evaluation
            └─ First Screen Render
                 └─ Prefetched Data Available  ← Show this fast
```

### Key Techniques

**1\. Synchronous token check with MMKV**

```ts
// MMKV reads are synchronous — no async waterfall
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();
const token = storage.getString('access_token'); // ~0.1ms
```

AsyncStorage reads are async — they add 5–20ms of latency on the critical path. MMKV eliminates this.

**2\. Prefetch critical data before navigation**

```tsx
// app/_layout.tsx
export default function RootLayout() {
  useEffect(() => {
    // Warm up the feed cache in the background
    // By the time the user reaches the feed tab, data is ready
    queryClient.prefetchInfiniteQuery({
      queryKey: ['feed'],
      queryFn: () => feedApi.getFeed(),
    });
  }, []);
}
```

**3\. Expo Router's** `expo-splash-screen` **integration**

```tsx
// app/_layout.tsx
import * as SplashScreen from 'expo-splash-screen';

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    Promise.all([
      Font.loadAsync({ ... }),       // fonts
      Asset.loadAsync([...]),        // critical images
      checkAuthStatus(),             // token validation
    ]).then(() => {
      setReady(true);
      SplashScreen.hideAsync();
    });
  }, []);

  if (!ready) return null;
  return <Stack />;
}
```

**4\. Lazy-load heavy feature modules**

```tsx
const ReelsPlayer = lazy(() => import('@/features/reels/components/ReelsPlayer'));
const MapView = lazy(() => import('@/features/maps/components/MapView'));

// These are NOT in your initial bundle
// They load on first navigation to the relevant tab
```

* * *

## Performance Considerations in Production

### The FlashList Problem

`FlatList` re-renders every visible cell on scroll. At Instagram's feed density, this destroys frame rate on mid-range devices. The solution is Shopify's `FlashList`:

```tsx
import { FlashList } from '@shopify/flash-list';

export function FeedList({ posts }: { posts: Post[] }) {
  return (
    <FlashList
      data={posts}
      renderItem={({ item }) => <PostCard post={item} />}
      estimatedItemSize={400}           // Critical — prevents layout thrash
      keyExtractor={(item) => item.id}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
      removeClippedSubviews                // Unmount off-screen cells
      maxToRenderPerBatch={5}
      windowSize={10}
    />
  );
}
```

### Image Optimization

Instagram's images load fast because they're:

1.  Served from a CDN closest to the user
    
2.  Progressively loaded (blur hash → thumbnail → full)
    
3.  Prefetched for the next 3–4 scroll positions
    

```tsx
import { Image } from 'expo-image';

<Image
  source={post.imageUrl}
  placeholder={post.blurHash}        // Show placeholder immediately
  contentFit="cover"
  transition={200}                   // Smooth fade-in
  cachePolicy="memory-disk"          // Cache aggressively
/>
```

### Hermes + React Compiler

Enable Hermes (Meta's JS engine for React Native) in your `app.json`:

```json
{
  "expo": {
    "jsEngine": "hermes"
  }
}
```

Hermes pre-compiles JS to bytecode at build time, reducing parse time on device by 40–60%. This is non-negotiable for production.

* * *

## The Four Apps: Scalability Deep-Dive

### Instagram → Feeds and Media

**Core challenge:** Rendering an infinite, heterogeneous feed (photos, videos, carousels, ads, suggested content) efficiently.

**Architecture decisions:**

*   Feed is a single `FlashList` with a discriminated union item type
    
*   Each post type renders its own component (`PhotoPost`, `CarouselPost`, `ReelPreview`, `SuggestedUsers`)
    
*   Media is lazy-loaded — only the currently visible item plays video
    
*   Stories are a separate horizontal list, independent of the feed scroll
    

**Expo Router structure:**

```plaintext
app/(app)/(feed)/
  index.tsx           ← FlashList of mixed post types
  [postId].tsx        ← Full post view
  stories/[userId].tsx ← Story viewer (full screen)
  comments/[postId].tsx ← Comment sheet
```

**Tradeoff:** Instagram made a controversial choice — the feed is fully server-ranked. The app has no local personalization logic. This simplifies the client enormously but means the app is useless offline.

* * *

### WhatsApp → Realtime Messaging

**Core challenge:** Billions of messages per day with end-to-end encryption, delivery receipts, and offline reliability.

**Architecture decisions:**

*   Message state has three tiers: sent to server (✓), delivered to device (✓✓), read (✓✓ blue)
    
*   Conversation list is paginated — you don't load all conversations at boot
    
*   Media messages are sent via a separate upload pipeline, not through the socket
    
*   Unread counts are maintained locally to avoid a round-trip on every app open
    

**Expo Router structure:**

```plaintext
app/(app)/(messaging)/
  index.tsx           ← Conversation list
  [conversationId]/
    index.tsx         ← Chat thread
    media.tsx         ← Shared media gallery
    info.tsx          ← Conversation details
```

**Tradeoff:** WhatsApp chose client-side encryption (Signal Protocol), which means their servers cannot read messages. This eliminates spam filtering, content moderation, and AI features in messages — a significant product constraint for privacy.

* * *

### Uber → Maps and Live Location

**Core challenge:** High-frequency location updates, complex map state, and the need to work on low-end Android devices in emerging markets.

**Architecture decisions:**

*   Map rendering is fully native (Mapbox/Google Maps) — JavaScript never touches coordinates for smooth 60fps animation
    
*   Driver location updates arrive at ~10Hz over WebSocket
    
*   The "finding a driver" state is a state machine: `idle → searching → matched → en_route → arriving → in_trip → completed`
    
*   ETA calculations happen on the server, not the client
    

**Expo Router structure:**

```plaintext
app/(app)/(ride)/
  index.tsx           ← Map + booking UI
  confirm.tsx         ← Ride confirmation
  tracking/[rideId].tsx ← Live tracking
  history/index.tsx   ← Past trips
  history/[tripId].tsx ← Trip detail + receipt
```

**Tradeoff:** Uber's biggest architectural constraint is battery life. Location polling is expensive. They use a variable frequency — high frequency while a trip is active, low frequency while the app is backgrounded. Background tracking requires platform-specific native modules.

* * *

### Netflix → Heavy Content Delivery

**Core challenge:** Delivering high-quality video to 270M+ subscribers across every network condition and device.

**Architecture decisions:**

*   Content metadata and the browse UI are completely separate from video playback
    
*   The browse UI prefetches the next row of content as you scroll down
    
*   Video player is instantiated early (off-screen) so playback starts without delay
    
*   Downloads are managed by a queue system with priority (in-progress downloads persist across app restarts)
    

**Expo Router structure:**

```plaintext
app/(app)/(browse)/
  index.tsx                 ← Home row grid
  [contentId]/
    index.tsx               ← Title detail page
    episodes.tsx            ← Episode list (series)
    more-like-this.tsx
  search/index.tsx
  downloads/index.tsx       ← Offline content
app/(player)/
  [contentId].tsx           ← Full-screen player (separate layout)
```

The player lives in its own route group with no tabs, headers, or status bar — a completely immersive shell.

**Tradeoff:** Netflix uses DRM (Widevine on Android, FairPlay on iOS) for protected content. This requires platform-specific native modules that are not available in Expo Go — they're only supported in development builds. Any serious video app must use `eas build` from day one.

* * *

## Tradeoffs at Scale: What Teams Actually Decide

| Decision | Small App | Scale App | Why |
| --- | --- | --- | --- |
| State management | Context API | Zustand + TanStack Query | Context re-renders entire trees |
| Storage | AsyncStorage | MMKV | MMKV is 10x faster, synchronous |
| Lists | FlatList | FlashList | FlatList can't handle >500 items |
| Images | `<Image>` | expo-image | Blurhash, disk cache, CDN-aware |
| Navigation | React Navigation manual | Expo Router file-based | Type-safe params, less boilerplate |
| API layer | `fetch()` in components | Axios + TanStack Query | Caching, retries, deduplication |
| Realtime | Polling | WebSockets + fallback | Lower latency, less server load |
| Build | Expo Go | EAS Build | Native modules, DRM, background tasks |

* * *

## Closing Thoughts: Architecture as a Long Game

The apps we discussed — Instagram, WhatsApp, Uber, Netflix — were not architected perfectly from day one. They evolved. But they evolved faster and broke less because their foundations were built around clear boundaries: feature ownership, typed contracts, layered concerns, and explicit navigation hierarchies.

Expo Router doesn't give you all of this for free. But it gives you the right primitives:

*   File-based navigation that enforces structure
    
*   Nested layouts that separate concerns visually and logically
    
*   Route groups that create navigation domains
    
*   Dynamic routes with type-safe params
    

The rest is discipline. Feature-based architecture is not a framework — it's a decision you make every time you create a new file. The question isn't "what folder does this go in?" It's "who owns this, and how does it change without breaking everything else?"

That's the difference between an app that ships once and an app that ships forever.

* * *

*Written for the Web Dev Cohort 2026 — Mobile Development Module*

*Topics covered: Expo Router, React Native architecture, feature-based folder structure, navigation patterns, authentication flow, state management, realtime systems, offline-first, performance optimization, app startup, scalable mobile engineering*
