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
β€’9 min read

When you are building a small hobby project in React Native, you can throw your files almost anywhere and things will work just fine. But when you scale up to apps like Instagram, WhatsApp, or Uber, a messy codebase becomes an absolute nightmare.

At that scale, multiple teams are pushing code simultaneously, features are deeply interconnected, and a single performance bottleneck can ruin the experience for millions of users.

Let's dive under the hood and look at how modern large-scale mobile apps are structured, how to design an architectural blueprint using Expo Router, and the major tradeoffs teams face at production scale.

πŸ—οΈ Why Architecture Matters in React Native

In massive applications, architecture isn't just about keeping files tidy. It directly impacts your business and developer experience. A poor architectural setup leads to three major problems:

  • Spaghetti Code: Tight coupling where changing a line of code in the chat module unexpectedly breaks the checkout screen.

  • Merge Conflicts: If fifty developers are constantly modifying a single global routing configuration file, team velocity drops to zero.

  • Performance Degradation: Giant bundle sizes and unoptimized startup times mean users delete the app before it even loads.

To solve this, modern architecture relies heavily on Feature-Based Separation.

Instead of grouping files by their technical type (e.g., keeping all components in one massive folder and all hooks in another), we group files by what they do.

The Feature-Based Blueprint

Inside a feature module, you keep everything required for that specific feature to function independently:

src/
 ── features/
β”‚   β”œβ”€β”€ auth/
β”‚   β”œβ”€β”€ chat/
β”‚   β”‚   β”œβ”€β”€ components/       # Chat-specific components
β”‚   β”‚   β”œβ”€β”€ hooks/            # Chat business logic
β”‚   β”‚   β”œβ”€β”€ services/         # API & WebSocket handlers
β”‚   β”‚   └── types.ts          # Type definitions
β”‚   β”œβ”€β”€ feed/
β”‚   └── tracking/
β”œβ”€β”€ shared/                   # Global reusable design system & utilities
β”‚   β”œβ”€β”€ components/           # Button, Input, Modal
β”‚   └── network/              # Global Axios/Fetch client

This ensures that if a developer needs to rewrite the entire chat UI, they only ever touch files inside the features/chat/ directory, leaving the rest of the app completely isolated and safe.


πŸ“ File-System Routing with Expo Router

Expo Router brings file-system routing (similar to Next.js on the web) to React Native. This completely eliminates the need for a massive, centralized NavigationContainer configuration file that everyone has to edit.

Instead, your file tree is your app structure. Expo Router leverages Shared Layouts and Nested Routing seamlessly using native navigation constructs under the hood.

Here is how a real-world, scalable directory layout looks inside the app/ folder:

app/
β”œβ”€β”€ (auth)/                   # Authentication Group
β”‚   β”œβ”€β”€ _layout.tsx           # Defines native Stack or empty layout for Auth
β”‚   β”œβ”€β”€ login.tsx             # /login
β”‚   └── register.tsx          # /register
β”œβ”€β”€ (main)/                   # Main App Protected Group
β”‚   β”œβ”€β”€ _layout.tsx           # Defines global layout (e.g., bottom Tabs)
β”‚   β”œβ”€β”€ (tabs)/               # Nested Tab Navigation
β”‚   β”‚   β”œβ”€β”€ feed/
β”‚   β”‚   β”‚   β”œβ”€β”€ _layout.tsx   # Stack layout for drilling deep into posts
β”‚   β”‚   β”‚   β”œβ”€β”€ index.tsx     # /feed (Main Feed)
β”‚   β”‚   β”‚   └── [postId].tsx  # /feed/123 (Dynamic Post Detail)
β”‚   β”‚   β”œβ”€β”€ chat/
β”‚   β”‚   β”‚   β”œβ”€β”€ index.tsx     # /chat (Conversations List)
β”‚   β”‚   β”‚   └── [roomId].tsx  # /chat/room_abc (Direct Message Screen)
β”‚   β”‚   └── profile.tsx       # /profile
β”œβ”€β”€ _layout.tsx               # Root Entry: Context Providers & Auth Gate
└── index.tsx                 # Initial router redirect logic

The Power of _layout.tsx and Slots

The root _layout.tsx acts as the grand orchestrator. It wraps your app in all necessary global context providers (State, Theme, API Clients) and sets up the foundational navigation shell.

// app/_layout.tsx
import { Slot, useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
import { useAuthStore } from '@/features/auth/store';

export default function RootLayout() {
  const { isAuthenticated, isInitialized } = useAuthStore();
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    if (!isInitialized) return;

    // Check if the user is currently inside the (auth) group folders
    const inAuthGroup = segments[0] === '(auth)';

    if (!isAuthenticated && !inAuthGroup) {
      // Redirect unauthenticated users to login immediately
      router.replace('/login');
    } else if (isAuthenticated && inAuthGroup) {
      // Redirect logged-in users away from auth screens to the main feed
      router.replace('/feed');
    }
  }, [isAuthenticated, isInitialized, segments]);

  // Renders the matched child route dynamically
  return <Slot />;
}

Why this scales: Adding a new screen is as simple as creating a file. No global navigation config files to fight over, and the folder layout implicitly handles the visual layering of stack and tab bars.


πŸ”„ Networking, State, and Offline-First Caching

Large apps treat data in two distinct ways: server-driven state (API data) and client-driven state (UI toggles, themes, user sessions). Mixing them into a single giant global state store is a recipe for terrible performance.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     Root App Layer                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β–Ό                               β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Server State Layer    β”‚       β”‚ Client State Layer    β”‚
β”‚ (TanStack Query)      β”‚       β”‚ (Zustand / Jotai)     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ β€’ Cache Management    β”‚       β”‚ β€’ Authentication Tok  β”‚
β”‚ β€’ Optimistic Updates  β”‚       β”‚ β€’ App Theme Setting   β”‚
β”‚ β€’ Automatic Retries   β”‚       β”‚ β€’ Navigation State    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

1. Server State (The API Layer)

For handling APIs, large systems rely heavily on TanStack Query (React Query) rather than raw useEffect calls or global state dispatch patterns. It manages caching, deduplicates requests, and handles background synchronization out of the box.

2. Client State

For pure local client state, lightweight libraries like Zustand or Jotai are preferred over classic Redux. They use a decoupled selector model, preventing your entire app layout from re-rendering when a single local variable changes.

3. Offline-First Caching (The Netflix/Instagram Model)

When a user opens Instagram on a subway, they don't see a blank loading spinner; they see the feed items cached from their last session.

To achieve this, we hook up TanStack Query to an ultra-fast, local key-value database like MMKV instead of the sluggish built-in AsyncStorage.

// shared/network/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // Keep unused cache alive for 24 hours
      staleTime: 1000 * 60 * 5,    // Consider data fresh for 5 minutes
    },
  },
});

// Sync data seamlessly to ultra-fast native memory storage
export const clientPersister = {
  persistClient: async (client: any) => {
    storage.set('app-cache', JSON.stringify(client));
  },
  restoreClient: async () => {
    const cache = storage.getString('app-cache');
    return cache ? JSON.parse(cache) : undefined;
  },
  removeClient: async () => {
    storage.delete('app-cache');
  },
};

⚑ Real-Time Subsystems at Scale

High-performance real-time features require highly optimized architecture to keep your JS thread running at a smooth 60 frames per second.

πŸ’¬ Chat Systems (The WhatsApp Model)

For chat systems, maintaining long-lived persistent connections is essential. While simple apps use raw WebSockets or Socket.io, massive chat applications leverage binary protocols like MQTT or structured buffers like Protocol Buffers (Protobuf) over TCP.

This minimizes the payload size down to the absolute bare minimum bytes, significantly saving device battery life and mobile network data usage.

πŸ“ Live Ride Tracking (The Uber Model)

If you update a map marker every single time a driver's GPS emits a tiny change, your app will choke and freeze.

To make tracking fluid, engineers implement two core strategies:

  • Throttling & Interpolation: We restrict incoming coordinates to tick once every few seconds, and then use mathematical animations (like layout transitions or custom re-animating drivers) to smoothly slide the vehicle marker across the map layout.

  • Geohashing: The backend partitions map spaces into precise grid cells, meaning the client only listens to coordinates within their immediate spatial grid cluster.


πŸš€ Production Performance & Startup Optimization

No matter how great your features are, users will abandon an app if it takes more than two seconds to interactive on startup.

1. Bundle Splitting via Lazy Evaluation

By default, JavaScript evaluates everything loaded at boot time. If your chat code is massive, it slows down the feed load time. To combat this, look into utilizing dynamic imports or chunked modules, ensuring you only initialize deep code blocks right when they are needed.

2. Native Machinery: FlashList vs FlatList

When rendering thousands of heavy feed posts, standard FlatList recreates DOM-like components aggressively, causing garbage collection spikes and UI stutter. Large teams swap to Shopify’s FlashList, which recycles platform native views under the hood without continuously destroying and recreating UI nodes.

3. The Ultimate Mobile Engine: Hermes

Always ensure Hermes is enabled in your project configurations. Hermes is a JavaScript engine custom-built by Meta for running React Native efficiently.

Instead of parsing raw JS source code at runtime on the user's phone, Hermes compiles your JavaScript bundle into optimized bytecode ahead-of-time (AOT) during your production build process. This slashes app startup times down drastically and significantly lowers overall memory consumption.


βš–οΈ Architectural Tradeoffs: The Realities of Scale

Every architectural benefit has a hidden cost. When making decisions, you have to choose which pain point your team is willing to manage:

Choice The Pros The Cons Who Does It?
Monolith (All Features in One Repo) Easy to share types, simpler CI/CD pipelines, single dependency management. Slow CI/CD pipelines, messy git histories, risky releases. Early-mid stage scale apps.
Micro-Frontends / Monorepos High team isolation, modular deployments, independent versioning control. Massive overhead setup, runtime version conflicts, highly complex build systems. Uber / Netflix
Expo Continuous Native Workflow Rapid OTA updates, unified JavaScript tooling ecosystem, zero native configuration dread. Heavy dependency on third-party plugin configurations for custom native libraries. Modern enterprise fast-moving scale apps.
Pure Custom Native Bridges Maximum performance optimization, fine-grained access to bare metal device APIs. High maintenance overhead, requires dedicated Swift/Kotlin expert engineering teams. Meta (Instagram / WhatsApp)

There is no "perfect" stack. The ideal choice is always the minimal structure that enables your engineering teams to ship code independently, safely, and predictably without tripping over one another.