Architecting React Native Apps at Scale: How Instagram, WhatsApp, Uber & Netflix Think About Mobile
Most apps don't fail because of bugs. They fail because nobody thought about what happens when the codebase triples in size, the team doubles, and the feature backlog never ends.
Why Simple Folder Structures Break at Scale
Every React Native app starts the same way. A fresh npx create-expo-app and a folder structure that looks reasonable on day one:
src/
components/
screens/
utils/
App.tsx
This works fine for a side project. It works fine for a team of two. It even works for a couple of months.
Then something happens. The app grows. The team grows. And suddenly:
components/has 200 files and nobody knows what belongs wherescreens/is a graveyard of abandoned features nobody wants to deleteA change to the "user profile" screen breaks the "edit post" screen because they secretly share state through a global store that nobody documented
Onboarding a new developer takes two weeks of "just ask someone"
This is the moment architecture starts to matter. Not as an academic exercise — but as a survival mechanism.
The Core Mindset Shift: Small-App Thinking vs. Production Engineering Thinking
| Small-App Thinking | Production Engineering Thinking |
|---|---|
| "Where does this file go?" | "Which feature does this belong to?" |
| Global state for everything | Scoped state, lifted only when needed |
| One navigation file | Nested, typed, feature-scoped navigation |
fetch() calls inside components |
Dedicated API layer with caching & retry |
| Auth checked at runtime | Auth enforced at the router level |
| Build and ship | Build, test, monitor, scale, maintain |
The goal isn't complexity for its own sake. The goal is a codebase where any developer can work on any feature without needing to understand the entire system.
Feature-Based Architecture: The Foundation of Scale
Rather than organizing by type (components, screens, hooks), production apps organize by feature. Every feature is a self-contained vertical slice of the application.
src/
features/
auth/
components/
hooks/
store/
api/
screens/
index.ts ← Public API of the feature
feed/
components/
hooks/
store/
api/
screens/
index.ts
messaging/
components/
hooks/
store/
api/
screens/
index.ts
profile/
...
shared/
components/ ← Truly reusable UI primitives
hooks/ ← Cross-feature hooks
utils/
types/
constants/
core/
navigation/
api/ ← Base HTTP client, interceptors
storage/ ← AsyncStorage abstraction
config/
The rule: a feature can import from shared/ and core/, but features never import from each other. Cross-feature communication happens through a shared store or events — never through direct imports.
This boundary is what makes it possible to work on messaging/ without touching feed/, to delete stories/ without breaking profile/, and to extract a feature into a separate micro-frontend if the team ever needs to.
Folder Architecture with Expo Router
Expo Router brings file-system based routing to React Native, inspired by Next.js. This is a major step forward for large apps because your folder structure becomes your navigation structure.
app/
_layout.tsx ← Root layout, providers, fonts
(auth)/
_layout.tsx ← Auth group layout (unprotected)
login.tsx
signup.tsx
forgot-password.tsx
(app)/
_layout.tsx ← Protected layout (checks auth)
(tabs)/
_layout.tsx ← Tab bar layout
index.tsx ← Feed / Home
search.tsx
notifications.tsx
profile.tsx
post/
[id].tsx ← Dynamic route: /post/123
story/
[userId].tsx
settings/
_layout.tsx
index.tsx
privacy.tsx
notifications.tsx
chat/
index.tsx ← Conversation list
[conversationId].tsx ← Individual chat
modal/
share.tsx ← Presented as modal
report.tsx
Route Groups: The Hidden Superpower
The (auth) and (app) folders are route groups — they organize routes without affecting the URL path. This lets you:
Apply different layouts (authenticated vs. unauthenticated)
Wrap groups in different providers
Gate entire sections of the app at the layout level
Authentication Flow Architecture
Auth is one of the most mishandled parts of mobile apps. The naive approach: check AsyncStorage in each screen. The result: flash of wrong content, race conditions, and security gaps.
The production approach: enforce auth at the layout level.
App Launches
│
▼
Root _layout.tsx
│
├─── Loads auth state from secure storage
│
├─── Auth state: LOADING
│ └─── Show splash / skeleton
│
├─── Auth state: UNAUTHENTICATED
│ └─── Redirect → /(auth)/login
│
└─── Auth state: AUTHENTICATED
└─── Render /(app)/_layout.tsx
└─── Protected content
In Expo Router, this looks like:
// app/(app)/_layout.tsx
export default function AppLayout() {
const { user, isLoading } = useAuth();
if (isLoading) return <SplashScreen />;
if (!user) {
return <Redirect href="/login" />;
}
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="post/[id]" options={{ presentation: 'card' }} />
</Stack>
);
}
The protected (app) layout never renders if the user isn't authenticated. No per-screen checks needed. No race conditions. Auth is a concern of infrastructure, not of individual screens.
Token Lifecycle
Login Success
│
▼
Store accessToken (memory / secure store)
Store refreshToken (encrypted secure store)
│
Every API Request
│
├─── Attach accessToken to Authorization header
│
└─── On 401 Response
│
├─── Call /auth/refresh with refreshToken
│
├─── Success → store new tokens, retry request
│
└─── Failure → clear all tokens, redirect to login
This token refresh logic belongs in the API layer interceptor — not in individual screens or hooks.
API Handling and the Networking Layer
Production apps never call fetch() directly from components. There are too many concerns to handle at that level: base URL configuration, auth headers, token refresh, retry logic, error normalization, and request logging.
The API Layer
app/ ← UI layer (screens, components)
│
▼
Feature API modules ← Feature-specific endpoints
(features/feed/api/index.ts)
│
▼
Base HTTP client ← Axios / custom fetch wrapper
(core/api/client.ts)
│
├─── Request interceptor: attach auth token
├─── Response interceptor: handle 401, normalize errors
└─── Retry logic for network failures
│
▼
Backend / External APIs
// core/api/client.ts
const apiClient = axios.create({
baseURL: Config.API_URL,
timeout: 10_000,
});
apiClient.interceptors.request.use(async (config) => {
const token = await TokenStore.getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await AuthService.refreshTokens();
return apiClient(error.config); // Retry original request
}
return Promise.reject(normalizeError(error));
}
);
Data Fetching with React Query
For server state, TanStack Query (React Query) has become the standard in production apps. It handles:
Caching and deduplication
Background refetching
Optimistic updates
Pagination and infinite scrolling
Loading and error states
// features/feed/hooks/useFeed.ts
export function useFeed() {
return useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam = 0 }) => FeedAPI.getPosts({ offset: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextOffset,
staleTime: 1000 * 60 * 2, // 2 minutes
});
}
State Management Strategies for Large Apps
Not all state is the same. Using a single global store for everything is as much a mistake as having no store at all.
State Types
│
├── Server State → TanStack Query / SWR
│ (posts, users, messages, listings)
│
├── Global Client State → Zustand / Redux Toolkit
│ (auth user, theme, notification count)
│
├── Feature State → Zustand slice / Context
│ (chat draft, map filters, current order)
│
└── Local UI State → useState / useReducer
(modal open, input focus, animation)
The principle: state lives as close to where it's used as possible. Only promote state upward when two sibling features genuinely need to share it.
Zustand for Global State
// features/auth/store/authStore.ts
interface AuthStore {
user: User | null;
setUser: (user: User | null) => void;
isAuthenticated: () => boolean;
}
export const useAuthStore = create<AuthStore>((set, get) => ({
user: null,
setUser: (user) => set({ user }),
isAuthenticated: () => get().user !== null,
}));
Realtime Systems
Chat Architecture (WhatsApp-style)
WhatsApp handles ~100 billion messages per day. The mobile client architecture for any chat system follows a similar pattern:
User Types Message
│
▼
Local optimistic insert (message shows immediately with "sending" status)
│
▼
WebSocket emit → Backend
│
┌───┴─────────────────┐
│ │
Success Failure
│ │
Update status Show retry UI
to "sent" Keep message in outbox
│
▼
Server delivers to recipient
│
▼
WebSocket "delivered" event → Update status
│
▼
Recipient opens chat
│
▼
WebSocket "read" event → Show double blue ticks
Key pattern — optimistic updates: Messages appear instantly in the UI before the server confirms. This is what makes messaging feel real-time even on slow networks. If the server fails, the message state rolls back.
// Simplified optimistic message send
function useSendMessage(conversationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: MessagingAPI.sendMessage,
onMutate: async (newMessage) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['messages', conversationId] });
// Snapshot previous value
const previous = queryClient.getQueryData(['messages', conversationId]);
// Optimistically insert
queryClient.setQueryData(['messages', conversationId], (old: Message[]) => [
...old,
{ ...newMessage, id: `temp-${Date.now()}`, status: 'sending' },
]);
return { previous };
},
onError: (_, __, context) => {
// Roll back on error
queryClient.setQueryData(['messages', conversationId], context?.previous);
},
});
}
Live Location Tracking (Uber-style)
Uber's ride-tracking screen is deceptively complex. The driver's location updates every 2–4 seconds and the map needs to animate smoothly between positions.
Driver Device
│ (GPS coordinates every 2s)
▼
Backend Location Service
│
▼ (WebSocket push)
Rider's App
│
├── Update driver marker position
├── Animate marker between old and new coords
├── Recalculate ETA
└── Update route polyline if deviation detected
The critical mobile-side concern: marker animation. Don't just jump the marker to the new coordinate — interpolate between the old and new position over ~1 second. This makes the map feel smooth even when updates are infrequent.
function animateMarkerToCoordinate(
markerRef: MapMarker,
newCoordinate: LatLng,
duration = 1000
) {
// React Native Maps exposes animateMarkerToCoordinate
markerRef.animateMarkerToCoordinate(newCoordinate, duration);
}
Feed Live Updates (Instagram-style)
Instagram's feed isn't truly real-time — it uses a polling + push hybrid:
On app foreground: check for new posts
Push notification: signal that new content exists ("See 12 new posts")
User taps → scroll to top → fetch new posts
Story rings: updated via WebSocket for live indicator
The "new posts available" pattern avoids constantly shifting content under the user's scroll position — a deliberate UX decision, not a technical limitation.
Offline-First Support and Caching
Production apps work when the network doesn't. This means data persists across sessions, the UI never shows a blank screen just because the network is slow, and writes that happen offline are queued and synced when connectivity returns.
App Opens
│
▼
Load from local cache (MMKV / SQLite)
│
▼
Show cached content immediately
│
▼
Fetch fresh data in background
│
├── Success → Update cache, update UI
│
└── Failure → Keep showing cache, show subtle "offline" indicator
The Offline Write Queue
User Action (like, comment, send message)
│
├── Network available?
│ └── YES → Execute immediately
│
└── Network unavailable?
└── Add to persistent offline queue (SQLite)
│
▼
Network restored (NetInfo event)
│
▼
Process queue in order
│
├── Success → Remove from queue
└── Failure → Retry with exponential backoff
Storage choices by use case:
MMKV: Blazing-fast key-value store. Best for auth tokens, user preferences, feature flags.
SQLite (via Drizzle or op-sqlite): Structured relational data. Best for messages, posts, offline queue.
React Query persistence: Serialize the query cache to MMKV on app background; rehydrate on launch.
App Startup Optimization
The time from tap to interactive UI is one of the most important metrics in mobile. Netflix, Uber, and Instagram all invest heavily in reducing this number. Here's the startup lifecycle and where time gets wasted:
User Taps App Icon
│
▼ [Native Boot]
JS Bundle Evaluation ← Biggest opportunity: code splitting, lazy loading
│
▼ [React Tree Mount]
Root Provider Setup ← Don't do heavy work here
Auth State Resolution ← Load from fast local storage (MMKV), not network
│
▼ [First Render]
Show Splash / Skeleton ← Never show a blank screen
│
▼ [Background]
Prefetch critical data ← User's feed, notifications, pending messages
Font loading ← Preload, don't block render
│
▼ [Interactive]
User sees content
Techniques
1. Lazy load non-critical screens
const SettingsScreen = lazy(() => import('../features/settings/SettingsScreen'));
2. Prefetch above-the-fold data during splash
// Start fetching feed data while splash is showing
// By the time auth resolves, data is already in cache
QueryClient.prefetchInfiniteQuery({ queryKey: ['feed'], queryFn: FeedAPI.getPosts });
3. Hermes engine — Enabled by default in modern Expo. Pre-compiles JS to bytecode at build time, eliminating parse time at runtime.
4. New Architecture (Fabric + TurboModules) — Synchronous JS-Native bridge. Eliminates the async bridge bottleneck for native module calls.
Performance Considerations in Production
FlatList Optimization (Instagram Feed)
The Instagram feed scrolls infinitely through high-resolution media. The key is never rendering more than what's visible.
<FlashList
data={posts}
renderItem={({ item }) => <PostCard post={item} />}
estimatedItemSize={400}
// Only render items within 2 viewports of current position
windowSize={5}
// Remove items from memory when far off-screen
maxToRenderPerBatch={3}
// Prevent the "white flash" on fast scroll
initialNumToRender={5}
onEndReached={fetchNextPage}
onEndReachedThreshold={0.5}
/>
FlashList (by Shopify) outperforms the standard FlatList by recycling item components instead of unmounting/remounting — the same technique used by native iOS and Android list views.
Image Performance (Netflix / Instagram)
// Use Expo Image instead of React Native's Image
import { Image } from 'expo-image';
<Image
source={{ uri: post.imageUrl }}
placeholder={post.blurhash} // Show colored placeholder while loading
contentFit="cover"
transition={200} // Smooth fade-in
cachePolicy="memory-disk" // Cache aggressively
/>
Avoiding Re-renders
// Memoize expensive list items
const PostCard = memo(({ post }: { post: Post }) => {
return <View>...</View>;
}, (prev, next) => prev.post.id === next.post.id && prev.post.likeCount === next.post.likeCount);
// Use useCallback for stable handler references
const handleLike = useCallback((postId: string) => {
likePost(postId);
}, [likePost]);
Shared Layouts and Nested Routing in Expo Router
Expo Router's nested layouts are what make complex navigation feel coherent. Each _layout.tsx is a persistent shell that wraps its children.
app/
_layout.tsx ← Root: Providers, fonts, theme
(app)/
_layout.tsx ← Auth check, protected shell
(tabs)/
_layout.tsx ← Tab bar (persists across tab switches)
index.tsx ← Feed
search.tsx
messages/
_layout.tsx ← Stack navigator for messaging
index.tsx ← Conversation list
[id].tsx ← Individual conversation
Shared headers and tab bars persist — navigating from Feed to Search doesn't unmount and remount the tab bar. This is handled automatically when the tab bar is defined in a layout.
Modals over any screen:
// From anywhere in the app, present a modal
router.push('/modal/share');
// In app/modal/share.tsx — presented as a sheet
export default function ShareModal() {
return <ShareSheet />;
}
Learning from Production Apps
Instagram → Feed Architecture
Feed items are a fixed-size card component. Height is estimated before data loads, preventing layout shift.
Media is preloaded 3–5 items ahead of scroll position.
Stories are a separate horizontal scroll view, independently virtualized.
Like animations are local state — they don't wait for network confirmation.
WhatsApp → Messaging Architecture
All messages are stored locally in SQLite first.
The UI reads from SQLite, not from network responses.
WebSocket events write to SQLite; the UI reactively updates via a database query subscription.
This is why WhatsApp feels fast even when reception is poor.
Uber → Maps and Location Architecture
The map is a native view — React Native renders UI over it, not inside it.
Location updates from the backend go through a WebSocket, not HTTP polling.
Driver ETA recalculates on every location update, displayed as an animated counter.
The ride state machine (SEARCHING → MATCHED → ARRIVING → IN_RIDE → COMPLETED) lives in a Zustand store and drives all screen rendering.
Netflix → Content Delivery Architecture
Content metadata (title, description, genres) is fetched and cached aggressively.
Video streaming itself is handled by native video players with adaptive bitrate (starting at low quality, upgrading as bandwidth allows).
The home screen rows are paginated independently — each row fetches its own content.
Downloads use a background fetch queue, respecting user preferences (Wi-Fi only, max storage).
Tradeoffs Teams Make at Scale
| Decision | Simple Approach | At-Scale Approach | The Tradeoff |
|---|---|---|---|
| State management | Redux everything | Zustand + React Query | More libraries, clearer boundaries |
| Navigation | React Navigation config | Expo Router file-system | Less flexibility, massively more clarity |
| Data fetching | useEffect + useState |
TanStack Query | Learning curve, eliminates an entire class of bugs |
| Feature organization | By type | By feature | More folders, zero cross-feature confusion |
| Offline support | Ignore it | Offline-first with sync | Significant complexity, dramatically better UX |
| Auth | Per-screen checks | Router-level enforcement | Architectural investment, security guarantee |
| Images | <Image /> |
Expo Image + CDN + blurhash | More setup, imperceptible load times |
| Lists | Basic FlatList | FlashList with tuning | Same API, 10x performance |
Conclusion
Architecture isn't about perfect systems. It's about making the right problems easier to solve as the app grows.
The companies building apps used by hundreds of millions of people didn't start with perfect architecture. They evolved toward it, driven by the pain of watching simple decisions become expensive problems at scale.
The patterns here — feature-based organization, router-level auth, a dedicated API layer, optimistic updates, offline queues, performance-tuned lists — aren't academic. They're the response to real failures in real production systems.
Start with the feature-based folder structure. Add the API layer early. Use React Query from day one. Think about auth at the router level. The rest can be added as complexity demands it.
Build for the codebase you'll have in a year, not just the one you have today.
Written for developers building with React Native and Expo. All architectural patterns are framework-agnostic in principle and applicable to any sufficiently complex mobile application.
Comments
Join the discussionNo comments yet. Be the first to comment.
