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:
Deep links are tied to your navigation structure. Changing routes breaks links shared by users.
State management changes require rewriting screens, not just services.
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.tsxwraps 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
Features can use
shared/, but shared cannot import from features.Features should not directly import from other features. Cross-feature communication goes through the global store or events.
API calls live in the feature's
services/folder, not in a global API folder.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:
Stack — Push/pop screens. Used for drilling into detail views.
Tabs — Side-by-side top-level sections. The bottom tab bar.
Modal — Overlays the current context. Full-screen modals, bottom sheets.
Expo Router composes these naturally through nested layouts.
Navigation Hierarchy for Large Applications
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 ofFlatList— 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
contentIdarrays 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
contentIdcomes 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.



