DEV Community

Cover image for Architecting Production React Native Apps: How Instagram, WhatsApp, and Uber Think About Scale
Kishan Agarwal
Kishan Agarwal

Posted on • Originally published at cosmoscribe.hashnode.dev

Architecting Production React Native Apps: How Instagram, WhatsApp, and Uber Think About Scale

Ok, before we go into the depths of these concepts, I want to tell you that we will take it easy. I don't want you to get overwhelmed by the company names in the title. We are not cloning Instagram. We are not rebuilding WhatsApp. We are going to use them as lenses — each one teaches you something specific about how production mobile engineering actually thinks.

By the end of this, you will have a mental model for structuring React Native applications that can grow without collapsing under their own weight. Whether you are building a solo project or joining a team that already has 40 engineers, this is the thinking you need.

Let's get into it.

The Dhaba Problem

There is a dhaba near my college that made the best chole bhature I have ever eaten. One cook, one kitchen, no menu, no system. The cook just knew where everything was. It worked perfectly.

Then they opened a second location.

Everything fell apart. The second cook didn't know the recipe. There was no system to teach it. The original cook couldn't be in two places. A dhaba can survive on one person's tribal knowledge. A chain cannot.

Most React Native codebases start as dhabas. Everything in one components/ folder. Navigation in App.js. API calls are scattered across screens. State managed with useState wherever it's needed. For the first few months, it works perfectly. The original developer knows where everything is.

Then the team grows. Or the app grows. Or both. Suddenly, nobody knows where anything is, touching one screen breaks three others, and onboarding a new developer takes a week just for them to understand the folder structure.

This is the problem architecture solves. Not performance — a flat structure performs just fine. Architecture is about how many people can work on this simultaneously without stepping on each other.

Feature-Based Architecture: Thinking in Districts

The shift you need to make is from type-based to feature-based organization.

Type-based is the instinct everyone starts with:

src/
├── components/
├── screens/
├── hooks/
├── utils/
└── services/

Looks clean. Falls apart at 30 screens. Your components/ folder has 80 files. hooks/ has 40. Finding the hook for the feed screen means digging through everything unrelated to it.

Feature-based reorganizes around product domains:

src/
├── features/
│   ├── feed/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── api/
│   │   └── store/
│   ├── messaging/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── api/
│   │   └── store/
│   ├── auth/
│   └── profile/
├── shared/
│   ├── components/    ← truly reusable UI only
│   ├── hooks/
│   └── utils/
└── core/
    ├── api/           ← base API client
    ├── navigation/
    └── storage/

Everything that belongs to the feed lives inside features/feed/. A developer working on messaging never needs to open the feed folder. A developer onboarding to the team can open one feature folder and understand that entire slice of the product.

The rule is simple: if a file is only used by one feature, it lives inside that feature. If it's used by two or more features, it moves to shared/.

Expo Router at Production Scale

Expo Router's file-based routing maps cleanly onto feature-based architecture. The app/ folder is your navigation layer. The src/features/ folder is your business logic layer. They stay separate.

├── app/                          ← Navigation (Expo Router)
│   ├── _layout.tsx               ← Root: fonts, providers, theme
│   ├── +not-found.tsx
│   │
│   ├── (auth)/
│   │   ├── _layout.tsx
│   │   ├── login.tsx
│   │   ├── register.tsx
│   │   └── forgot-password.tsx
│   │
│   └── (app)/
│       ├── _layout.tsx           ← Auth guard
│       ├── (tabs)/
│       │   ├── _layout.tsx       ← Tab navigator
│       │   ├── feed.tsx
│       │   ├── explore.tsx
│       │   ├── inbox.tsx
│       │   └── profile.tsx
│       │
│       ├── post/
│       │   ├── [id].tsx
│       │   └── [id]/comments.tsx
│       │
│       ├── chat/
│       │   ├── index.tsx
│       │   └── [conversationId].tsx
│       │
│       └── settings/
│           ├── index.tsx
│           ├── account.tsx
│           └── notifications.tsx
│
└── src/                          ← Business logic (feature-based)
    ├── features/
    ├── shared/
    └── core/

Your screen files in app/ are thin shells. They import from src/features/ and render. All the logic — data fetching, state, business rules — lives in the feature folder, not in the screen file.

// app/(app)/(tabs)/feed.tsx — thin shell
import { FeedContainer } from '@/features/feed/components/FeedContainer';

export default function FeedScreen() {
  return <FeedContainer />;
}

The screen file is five lines. The complexity lives where it belongs.

Authentication Architecture

Auth is the first place most apps get complicated. And the first place most developers cut corners, and they regret it later.

The architecture decision that matters is: where does auth state live, and who is allowed to read it?

// core/auth/AuthProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import * as SecureStore from 'expo-secure-store';
import { authApi } from './authApi';

type AuthState = {
  user: User | null;
  token: string | null;
  isLoading: boolean;
};

const AuthContext = createContext<AuthState & {
  login: (credentials: Credentials) => Promise<void>;
  logout: () => Promise<void>;
}>(null!);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<AuthState>({
    user: null,
    token: null,
    isLoading: true,
  });

  useEffect(() => {
    // On mount, check if a token exists in secure storage
    async function loadToken() {
      const token = await SecureStore.getItemAsync('auth_token');
      if (token) {
        const user = await authApi.getMe(token);
        setState({ user, token, isLoading: false });
      } else {
        setState(s => ({ ...s, isLoading: false }));
      }
    }
    loadToken();
  }, []);

  async function login(credentials: Credentials) {
    const { user, token } = await authApi.login(credentials);
    await SecureStore.setItemAsync('auth_token', token);
    setState({ user, token, isLoading: false });
  }

  async function logout() {
    await SecureStore.deleteItemAsync('auth_token');
    setState({ user: null, token: null, isLoading: false });
  }

  return (
    <AuthContext.Provider value={{ ...state, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);

Notice: the token lives in SecureStore, not AsyncStorage. Sensitive credentials belong in the device keychain, not in a plain key-value store.

The layout-level guard in Expo Router reads from this context:

// app/(app)/_layout.tsx
import { Redirect, Stack } from 'expo-router';
import { useAuth } from '@/core/auth/AuthProvider';

export default function AppLayout() {
  const { user, isLoading } = useAuth();

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

  return <Stack />;
}

One guard, covering every screen under (app)/. Add a new protected screen — create the file, and it's automatically guarded.

State Management at Scale

This is the question every growing React Native team debates. Redux? Zustand? Jotai? Context? Just React Query?

The honest answer: it depends on the type of state you are managing. There are three distinct categories, and they need different tools.

  • Server state — data that lives on a server and needs to be fetched, cached, synchronized, and invalidated. This is most of your app's data: user profiles, feed posts, and messages. Use TanStack Query (React Query). It handles caching, background refetching, optimistic updates, and loading/error states. This alone replaces 60% of what most apps use Redux for.
  • Client state — UI state that doesn't come from the server and doesn't need to persist: which tab is active, whether a modal is open, and the current theme. Use Zustand for the shared client state, useState for local. Zustand is 1KB, has no boilerplate, and works without a Provider wrapper.
  • Persistent state — data that needs to survive app restarts: user preferences, auth tokens, offline cache, draft messages. Use MMKV for fast synchronous key-value storage, SQLite (via expo-sqlite) for structured data like messages and posts.
    // A Zustand store for UI state — no boilerplate, no actions, no reducers
    import { create } from 'zustand';

type UIStore = {
activeTab: string;
isComposerOpen: boolean;
setActiveTab: (tab: string) => void;
toggleComposer: () => void;
};

export const useUIStore = create<UIStore>((set) => ({
activeTab: 'feed',
isComposerOpen: false,
setActiveTab: (tab) => set({ activeTab: tab }),
toggleComposer: () => set((s) => ({ isComposerOpen: !s.isComposerOpen })),
}));

No actions. No reducers. No dispatch. Just a store and setters.

The rule for choosing:

  • If TanStack Query can handle it, use TanStack Query.
  • If it's UI state, use Zustand.
  • If it needs to persist, use MMKV or SQLite.
  • Redux is the right choice when you need complex state machines, time-travel debugging, or your team already has deep Redux expertise.

Otherwise, the simpler stack wins.

The API Layer: One Client, Not Many

A mistake almost every app makes early on: API calls directly inside components. fetch('https://api.yourapp.com/users') scattered across 30 different files.

The problem hits when your base URL changes, you add auth headers, you switch from REST to GraphQL, or you need to add request logging. You make the change in 30 places. You miss one.

The API layer is a single module that owns all network communication.

// core/api/client.ts
import axios from 'axios';
import { getToken } from '@/core/auth/tokenStorage';

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

// Attach auth token to every request automatically
apiClient.interceptors.request.use(async (config) => {
  const token = await getToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Handle 401s globally — redirect to login
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      await clearToken();
      router.replace('/login');
    }
    return Promise.reject(error);
  }
);

Every feature's API module uses this client, not fetch directly.

// features/feed/api/feedApi.ts
import { apiClient } from '@/core/api/client';
import type { Post } from '../types';

export const feedApi = {
  getPosts: async (page: number): Promise<Post[]> => {
    const { data } = await apiClient.get('/posts', { params: { page } });
    return data;
  },

  likePost: async (postId: string): Promise<void> => {
    await apiClient.post(`/posts/${postId}/like`);
  },
};

Change the base URL once. Add a header once. Log every request once. The features don't care about any of it.

Realtime Systems: Chat, Live Updates, and Ride Tracking

This is where architecture gets genuinely interesting. Real-time systems are a different class of problem from standard request-response API calls.

Chat (WhatsApp mental model)

WhatsApp's core problem is not sending messages — that's a POST request. The hard problem is receiving messages when the app is in the background, displaying them in order, handling failed sends, and syncing across devices.

The real-time transport for chat is WebSockets. A persistent connection between the client and server that lets the server push messages to the client without the client asking.

// features/messaging/hooks/useMessageSocket.ts
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';

export function useMessageSocket(conversationId: string) {
  const ws = useRef<WebSocket | null>(null);
  const queryClient = useQueryClient();

  useEffect(() => {
    ws.current = new WebSocket(
      `wss://api.yourapp.com/ws/conversations/${conversationId}`
    );

    ws.current.onmessage = (event) => {
      const message = JSON.parse(event.data);

      // Push the incoming message into TanStack Query's cache
      // The UI updates automatically — no manual setState
      queryClient.setQueryData(
        ['messages', conversationId],
        (old: Message[] = []) => [...old, message]
      );
    };

    ws.current.onclose = () => {
      // Reconnection logic here
    };

    return () => ws.current?.close();
  }, [conversationId]);
}

The incoming message lands directly in TanStack Query's cache. Every component subscribed to ['messages', conversationId] re-renders automatically. No event emitters, no global state, no manual dispatch.

Live Location (Uber mental model)

Uber's driver location problem is different from chat. Messages can tolerate a two-second delay. A driver's location on a map cannot, the user expects it to move smoothly in near real-time.

The transport for this is WebSockets or Server-Sent Events, with the client receiving location updates at a high frequency and the map component interpolating between positions for smooth animation.

// features/ride/hooks/useDriverLocation.ts
import { useEffect, useState } from 'react';

type LatLng = { latitude: number; longitude: number };

export function useDriverLocation(rideId: string) {
  const [location, setLocation] = useState<LatLng | null>(null);

  useEffect(() => {
    const ws = new WebSocket(`wss://api.yourapp.com/ws/rides/${rideId}/location`);

    ws.onmessage = (event) => {
      const { latitude, longitude } = JSON.parse(event.data);
      setLocation({ latitude, longitude });
    };

    return () => ws.close();
  }, [rideId]);

  return location;
}

The map component takes this location and animates the driver marker. The WebSocket delivers new coordinates every 1–2 seconds. The map library handles the smooth animation between points.

Live Content Updates (Instagram/Netflix mental model)

Instagram's feed updates when someone you follow posts. Netflix showing a download's progress. These don't need WebSockets; the urgency is lower, and maintaining a persistent connection for every user is expensive.

Server-Sent Events (SSE) work here. A one-directional push channel from server to client, lighter than WebSockets, reconnects automatically.

For lower-frequency updates, polling with TanStack Query is often the right answer. Refetch the feed every 30 seconds. Simple, predictable, no persistent connection cost.

// Polling with TanStack Query — Instagram "new posts" pattern
const { data: newPosts } = useQuery({
  queryKey: ['feed', 'new'],
  queryFn: feedApi.getNewPosts,
  refetchInterval: 30_000,       // Every 30 seconds
  refetchIntervalInBackground: false, // Pause when app is backgrounded
});

Sounds hard, right? TanStack Query handles all of it in two lines.

Offline-First Support

You might be thinking, "My app uses an API. If there's no internet, there's no data. What is there to cache?"

A lot. And this is the difference between an app that feels native and one that feels like a web page in a frame.

Offline-first means the app renders content from local cache immediately on launch, syncs with the server in the background, and queues writes when there's no connection to replay when connectivity returns.

Reading offline: TanStack Query caches responses in memory. For persistence across app restarts, combine it with MMKV as the persistence layer.

// core/api/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

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

export const persister = createSyncStoragePersister({
  storage: mmkvStorageAdapter,
});

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // Cache for 24 hours
    },
  },
});

Writing offline: Queue mutations locally when offline, replay them when the connection returns.

// Optimistic update with offline queue
const { mutate: likePost } = useMutation({
  mutationFn: feedApi.likePost,
  onMutate: async (postId) => {
    // Update the cache immediately — the UI feels instant
    await queryClient.cancelQueries({ queryKey: ['posts'] });
    queryClient.setQueryData(['posts'], (old: Post[]) =>
      old.map(p => p.id === postId ? { ...p, liked: true, likes: p.likes + 1 } : p)
    );
  },
  onError: (err, postId, context) => {
    // If it fails, roll back
    queryClient.setQueryData(['posts'], context.previousPosts);
  },
});

The user taps like. The UI updates instantly. The network request happens in the background. If it fails, the UI rolls back (Optimistic Updates). If the user is offline, TanStack Query's offline mutation queuing holds the request until connectivity returns.

App Startup Optimization

The two numbers that matter most for a startup: Time to Interactive (TTI) and perceived launch time, like LightHouse WebAnalytics for web

TTI is how long before the user can actually use the app. Perceived launch time is how quickly it feels like something is happening, which you can improve even without reducing actual TTI.

The strategies:

1. Defer heavy initialization. Don't initialize analytics, crash reporting, push notification handlers, and feature flag SDKs synchronously on startup. Defer them.

// app/_layout.tsx
export default function RootLayout() {
  useEffect(() => {
    // These run after the first frame is painted, not before
    initAnalytics();
    initCrashReporting();
    registerPushHandlers();
  }, []);

  return <Stack />;
}

2. Prefetch critical data. Before the user navigates anywhere, start fetching the data they will almost certainly need.

// Prefetch feed data while auth is being verified
useEffect(() => {
  queryClient.prefetchQuery({
    queryKey: ['feed'],
    queryFn: feedApi.getPosts,
  });
}, []);

3. Use a native splash screen until fonts and critical assets are loaded, not until all data is fetched. Users accept a splash screen for 500ms. They don't accept a blank white screen for 2 seconds.

import * as SplashScreen from 'expo-splash-screen';
import { useFonts } from 'expo-font';

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [fontsLoaded] = useFonts({ 'Inter': require('./assets/Inter.ttf') });

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

  if (!fontsLoaded) return null;

  return <Stack />;
}

4. Lazy load heavy screens. React Native's Metro bundler doesn't code-split by default, but you can manually lazy-load screens that users rarely visit.

The Real Engineering Part

Let me share a very interesting problem my friend faced, where all of this stopped being theory and became urgent. He was working on a consumer app that had grown from 5 screens to 38 over 14 months. The original architecture was type-based — one big components/ folder, API calls in screens, state everywhere. Everything in App.js navigated to everything else via React Navigation string references.

At 38 screens and 4 developers, the codebase became genuinely dangerous. Changing the profile screen broke the settings screen because they shared a component that had grown side-effect dependencies on profile-specific state. Nobody knew. There were no boundaries.

The refactor took three weeks and touched nearly every file. The lesson is not "refactor early", that's too vague. The lesson is: the cost of architecture is paid once, the cost of no architecture is paid forever.

The specific decisions that matter at scale, and when they matter:

  • Feature isolation — matters from the first day a second developer joins. Two people in the same folder guarantee merge conflicts.
  • Typed API client — matters from the first time you add auth headers, change an endpoint, or need to mock the API in tests.
  • Server state separation — matters from the first screen that shows data from two different API endpoints. Without TanStack Query, you write loading/error/cache logic by hand, every time.
  • SQLite for structured offline data — matters when your app has more than a few thousand cacheable records. MMKV is fast, but it stores everything as strings. SQLite gives you queries, indexes, and relational structure.
  • WebSockets for real-time — matters the moment your app needs any data to update without user action. Polling works up to a point. WebSockets scale better and feel more alive.

The Catch: When Architecture Becomes the Product

You might be thinking, "This is a lot of setup before writing a single feature."

It is. And there is a real trap here.

Over-architected apps fail just as often as under-architected ones. The difference is who fails: under-architected apps fail when the team tries to scale. Over-architected apps fail when the team is too slow to ship anything before the runway runs out.

A solo developer building an MVP does not need feature-based architecture, a typed API client, offline sync, and a WebSocket layer. They need to ship. A flat structure, useState everywhere, and direct fetch calls is the right architecture for week one.

The question to ask is: What is the next forcing function?

  • Second developer joins → add feature boundaries
  • Auth gets complex → extract auth layer
  • API changes break multiple screens → add API client abstraction
  • Users complain about loading times → add TanStack Query
  • Need chat → add WebSockets

Architecture should lag slightly behind complexity, not anticipate it by six months. Building for Instagram's scale when you have 200 users is not engineering, it's procrastination with better vocabulary.

DIY: A Production-Grade Starting Point

Here is a starter structure you can clone for your next serious React Native project. Not a toy. Not a tutorial app. Something you can build a real product on.

my-app/
│
├── app/                          ← Expo Router (navigation only)
│   ├── _layout.tsx
│   ├── +not-found.tsx
│   ├── (auth)/
│   │   ├── _layout.tsx
│   │   ├── login.tsx
│   │   └── register.tsx
│   └── (app)/
│       ├── _layout.tsx           ← Auth guard here
│       ├── (tabs)/
│       │   ├── _layout.tsx
│       │   ├── index.tsx         ← feed
│       │   ├── explore.tsx
│       │   └── inbox.tsx
│       └── [feature]/
│           └── [id].tsx
│
├── src/
│   ├── features/                 ← Business logic, one folder per domain
│   │   ├── feed/
│   │   │   ├── components/
│   │   │   ├── hooks/
│   │   │   │   ├── useFeed.ts
│   │   │   │   └── useLikePost.ts
│   │   │   ├── api/
│   │   │   │   └── feedApi.ts
│   │   │   └── types.ts
│   │   ├── messaging/
│   │   │   ├── components/
│   │   │   ├── hooks/
│   │   │   │   ├── useConversations.ts
│   │   │   │   └── useMessageSocket.ts
│   │   │   ├── api/
│   │   │   └── types.ts
│   │   └── auth/
│   │       ├── AuthProvider.tsx
│   │       ├── authApi.ts
│   │       └── tokenStorage.ts
│   │
│   ├── shared/                   ← Used by 2+ features
│   │   ├── components/
│   │   │   ├── Button.tsx
│   │   │   ├── Avatar.tsx
│   │   │   └── EmptyState.tsx
│   │   ├── hooks/
│   │   │   ├── useNetworkStatus.ts
│   │   │   └── useDebounce.ts
│   │   └── utils/
│   │       ├── formatDate.ts
│   │       └── formatCurrency.ts
│   │
│   └── core/                     ← Infrastructure, not business logic
│       ├── api/
│       │   ├── client.ts         ← Axios instance + interceptors
│       │   └── queryClient.ts    ← TanStack Query client + MMKV persister
│       ├── storage/
│       │   └── mmkv.ts
│       └── config/
│           └── env.ts            ← process.env wrappers with types
│
├── assets/
├── constants/
│   └── theme.ts
└── app.json

The rule that keeps this clean: app/ imports from src/features/. src/features/ imports from src/shared/ and src/core/. Nothing imports from app/. One direction. No circles.

Try setting this up for your next project. It feels like overhead on day one. By week four, when you add a new feature without touching anything outside its folder, you will understand why it exists.

Happy Exploration!

Top comments (0)