RN

System design

Mobile Architecture Guide

A senior-level tour of mobile system design, mapped onto React Native and the New Architecture. This is the “how do you think about building a mobile app at scale” material.

01 · Thinking in systems — the CRDDS framework

System design is decision-making before code. On mobile specifically, interviewers want to see that you design for the worst phone, the worst network, and the worst moment — not lab conditions. This five-step framework keeps you structured under pressure:

StepWhat you doTime
C — ClarifyFunctional vs non-functional requirements, scale (DAU/MAU), platforms, online/offline expectations. Spend 4–6 min here — most candidates rush it and design the wrong system.~15%
R — Rough HLDDraw the high-level diagram: the four layers and the major components. A “city map, not a street map.”~20%
D — Deep diveZoom into one component (repository, chat service, image loader) — interfaces, methods, patterns, edge cases.~35%
D — Discuss trade-offsName the alternatives and why you chose one. This is the senior signal.~20%
S — SummarizeRecap the design, call out risks, handle follow-ups.~10%
Senior tell A line like “I'd use the Repository pattern plus stale-while-revalidate plus single-flight refresh” shows you can name patterns and combine them. Always say the trade-off out loud — “I'm trading freshness for speed here.”
Red flag Jumping straight to boxes-and-arrows without spending the first few minutes on Clarify — interviewers read that as a candidate who designs the wrong system fast, not the right one.
Your proof You already do the C-step instinctively — your CV literally lists “proactively ask clarifying questions.” Lead with that in any design round.

02 · The 4-layer architecture & patterns

Almost every mobile app shares the same skeleton. Memorize it — it's your HLD template:

LayerResponsibilityReact Native equivalent
UI / PresentationScreens, components, gestures, animations. “Dumb” — renders state, emits events.Function components, hooks, navigation, Reanimated
Business / DomainUse-cases, validation, app rules. Framework-agnostic, the most testable layer.Plain TS modules, custom hooks, state stores (Zustand/MobX)
Data / RepositoryCombines remote + local, caching, mapping DTOs → domain models. The app talks to repositories, never to the API directly.Repository modules wrapping React Query + local DB
NetworkHTTP engine, serialization, interceptors, retries.fetch/axios + Apollo/urql, interceptors, MMKV/SQLite

Pattern evolution: MVC → MVP → MVVM (view binds to a view-model exposing observable state) → MVI (one-way data flow, immutable state, reducers) → Clean Architecture (strict layer boundaries, dependencies point inward). In React Native, the idiomatic shape is essentially MVVM/MVI: components observe a store, dispatch actions, and a unidirectional data flow updates the UI.

Say this “I keep business logic out of components and in a domain layer, so it's testable without mounting UI and portable across screens.”
Red flag Putting validation and use-case logic straight into a screen component — it can't be unit-tested without a UI and it's locked to that one screen.
Your proof Your schema-driven Form Builder is layered thinking in action — a reusable domain package decoupled from any one screen, shared across a Turborepo monorepo.

03 · The networking layer

A production API client has six separated pieces: (1) the HTTP engine, (2) the serializer, (3) a typed API service interface, (4) interceptors for cross-cutting concerns (auth token, logging, retries), (5) DTOs that match the server JSON, and (6) the repository that wraps it all and adds caching. Never let DTOs leak into the UI — map them to domain models first.

Errors, retries, timeouts

Wrap calls in a result type (success / failure), set sane timeouts, and retry only idempotent requests with exponential backoff + jitter. Distinguish retryable (network, 5xx) from non-retryable (4xx) failures.

Pagination

StrategyHowUse when
Offset / limit?page=3&limit=20Small, stable lists; jump-to-page
CursorOpaque token to the next sliceFeeds & infinite scroll — stable under inserts (the usual right answer)
KeysetWHERE id < lastIdLarge datasets, performance-critical
Say this “The repository is the only thing that talks to the API — the UI only ever sees domain models.”
Red flag Letting the server's DTO shape leak straight into the UI — a backend field rename then breaks screens instead of one mapping function.
Your proof Discovery/global-directory surfaces with search + filters on Valt, and the AppSync GraphQL + OpenSearch data layer at Novacomp, are exactly this layer at work.

04 · Storage & caching

Two layers, different speeds and lifetimes: memory cache (RAM — fastest, lost on kill) and disk cache (SQLite / Realm / MMKV / files — survives restarts). Caching buys you speed, battery, data savings, and offline support; the cost is complexity — you must decide what to cache, where, when to evict, and when it's stale.

Eviction vs invalidation

  • Eviction = removing data to free space: LRU (drop least-recently-used) and TTL (expire after N seconds).
  • Invalidation = knowing cached data is stale. Strategies: time-based (TTL), event-based (push/mutation invalidates a key), and version-based (bump a version to bust old clients).
The pattern to name Stale-while-revalidate: show cached data instantly, fetch fresh in the background, reconcile. It's the heart of React Query and SWR — render cache, refetch, update. “I'd render the cache instantly, refetch in the background, and reconcile — that's stale-while-revalidate, and it's what React Query does under the hood.”
Red flag Treating eviction and invalidation as the same problem — eviction frees memory (LRU/TTL), invalidation is about correctness (knowing data is stale). An interviewer asking about stale data wants an invalidation strategy, not LRU tuning.
Your proof You shipped version-based cache invalidation on Valt so fixes could ship without breaking older clients, and you've used React Query and SWR for years.

05 · Offline-first design

Offline-first flips the model: the local database is the source of truth, and the network just keeps it in sync. The UI always reads from local storage, so the app keeps working with no signal. Three building blocks:

  • Optimistic updates — apply the change locally and update the UI immediately, then confirm with the server and roll back on failure. Makes likes, bookmarks, and sends feel instant.
  • Sync queue + replay — queue mutations made offline, then replay them in order when connectivity returns.
  • Conflict resolution — when local and server disagree: last-write-wins (simple), server-wins, or merge/CRDT (richest). Pick per data type.
Say this “The local database is my source of truth — the UI always reads from it, and the network just keeps it in sync.”
Red flag Reaching for optimistic updates on an initial data load — optimistic updates are for mutations you're confident will succeed (likes, sends); a first load with nothing to show yet should use staged/progressive loading instead.
Your proof You added optimistic updates across profiles and bookmarks on Valt — the exact pattern an interviewer wants you to describe here.

06 · Real-time updates

TechniqueHow it worksBest for
PollingClient asks every N secondsSimple, low-frequency data; wastes battery if abused
Long pollingServer holds the request until data is readyNear-real-time without WebSocket infra
WebSocketFull-duplex persistent connectionChat, presence, live trading — true two-way real-time
SSEOne-way server → client streamFeeds, notifications, live scores (server push only)
Push (FCM/APNs)OS-level delivery when app is backgrounded/killedRe-engagement, messages while app is closed

Decision guide: two-way + low latency → WebSocket. One-way stream → SSE. App closed → push notification. “Two-way and low-latency, I reach for WebSocket; one-way stream, SSE; and if the app can be closed, that's push, not sockets.”

Red flag Opening a WebSocket for updates that only happen every few minutes — a persistent connection keeps the radio active and drains battery (the radio's “tail energy”). Match the technique to the update frequency, not habit.
Your proof Twilio Conversations on Valt is a WebSocket-backed real-time system — you built the singleton provider, unread tracking, and push notifications on top of it.

07 · Performance essentials

  • App launch — minimize work before first paint; lazy-load, defer non-critical init. TurboModules help by initializing native modules on demand.
  • Smooth 60 FPS scrolling — virtualize long lists (FlatList/FlashList), keep renderItem cheap, stabilize keys, avoid anonymous inline functions/objects in props.
  • Re-render disciplineReact.memo, useMemo/useCallback, store selectors so a component re-renders only on the slice it uses. Profile first; don't sprinkle memo blindly.
  • Image & memory — right-size images, cache decoded bitmaps, release off-screen resources, watch for leaks.
  • Battery & network — batch network calls (the radio's “tail energy” makes 10 small calls far worse than 1), cancel work when the user leaves a screen, schedule background sync.

“I profile before I optimize — memoization is a targeted fix for a proven bottleneck, not a default.”

Red flag Sprinkling React.memo/useMemo everywhere without profiling first — it adds overhead and complexity while often fixing nothing real.
Your proof This is your signature: you eliminated re-render flickering and frozen screens on Valt by profiling, memoizing, and moving hot state into Zustand selectors.

08 · Security basics

  • HTTPS + certificate pinning — encrypt in transit; pin to defeat man-in-the-middle on hostile networks.
  • Token storage — never in AsyncStorage. Use the iOS Keychain and Android Keystore (e.g. react-native-keychain / expo-secure-store).
  • Encryption at rest — encrypt sensitive local data (SQLCipher, encrypted MMKV).
  • Auth flows — OAuth 2.0 / OIDC with PKCE, short-lived access tokens + refresh tokens, biometric gating for sensitive actions.

“Sensitive tokens go in the platform keystore, never in AsyncStorage — that's plaintext on disk.”

Red flag Storing auth tokens in AsyncStorage — it's unencrypted, readable on a rooted/jailbroken device. Tokens belong in the Keychain/Keystore, not app storage.
Your proof Biometric auth (FaceID/TouchID), Google Sign-In, Salesforce External Connected Apps for secure token management, Cognito + OAuth (Google/Apple/Facebook) — you've shipped the whole auth stack.

09 · React Native — the New Architecture

This is the highest-leverage topic for a senior RN interview in 2026. Know the before/after cold:

Old (legacy bridge)New Architecture
Async Bridge serializing JSON between JS & native — a batching bottleneckJSI (JavaScript Interface): JS holds C++ references and calls native synchronously, no JSON bridge
Paper renderer (async UI)Fabric: new concurrent renderer with a shared C++ shadow tree; enables synchronous layout & better React 18 concurrent features
NativeModules loaded eagerly at startupTurboModules: lazy, on-demand native modules → faster startup
Manual native interfacesCodegen: type-safe native interfaces generated from TS specs

Status to quote: the New Architecture has been the default since React Native 0.76 (late 2024); Hermes is the default JS engine; and the legacy bridge is being fully removed (disabled by default around 0.82). Expo apps get it out of the box on recent SDKs. Hermes matters because it precompiles to bytecode for faster startup, lower memory, and smaller bundles.

Say this “JSI replaced the bridge, Fabric replaced the renderer, TurboModules replaced native modules, and Codegen makes it all type-safe — it's the default since 0.76, so the old ‘RN is slow because of the bridge’ critique is basically historical now.”
Red flag Still citing “RN is slow because of the bridge” as a live criticism — the bridge is gone by default since 0.76 (JSI replaced it), so repeating that line reads as outdated knowledge, not current expertise.

10 · The RN threading model & mobile constraints

Classic React Native runs on three threads: the JS thread (your React code & business logic), the native/UI (main) thread (rendering, gestures), and a shadow thread (Yoga layout). Jank happens when you block the JS thread (heavy synchronous work) or thrash the bridge. The New Architecture + JSI reduce cross-thread cost, and tools like Reanimated run animations on the UI thread so they stay smooth even if JS is busy.

The four constraints that shape every decision: limited resources (battery, memory, CPU, storage), unreliable networks (2G→5G→offline), the app lifecycle (foreground / background / killed), and the fact that you ship to millions of different devices you don't control. Design for the worst case and the happy path takes care of itself.

“I check which thread is actually blocked before I fix anything — JS-thread work and UI-thread rendering fail differently.”

Red flag Assuming a laggy UI is always a rendering problem — heavy synchronous work on the JS thread blocks touch handling and app logic even while native-driven animations (Reanimated, useNativeDriver) keep animating smoothly on the UI thread.
Your proof You've debugged the render thread, driven a Hermes rollout, and shipped on a wide device range — speak to the threading model from having actually fought jank, not theory.

Deep dives

The senior playbook from two advanced guides, in a concept → example → problem → solution shape so each idea sticks as a real engineering decision, not a definition.

Startup

Cold start & Time-to-Interactive (TTI)

Concept TTI is the time from tap/launch to a screen the user can actually use. It's the headline cold-start metric and is dominated by JS bundle parse + everything you run before first paint.
Example A cold-start trace shows 2.5s: ~1.2s evaluating the JS bundle, ~0.8s in eager module/init work for screens the user hasn't even reached yet.
Problem Red flag: top-level imports of heavy libraries run their initialization at startup even when the first screen never uses them.
Solution Ship Hermes (bytecode, no parse), lazy/dynamically import non-critical screens, and defer non-urgent work with InteractionManager.runAfterInteractions(). Measure TTI before and after — don't guess. “I measure a TTI baseline before optimizing — anything else is guessing.”
Engine

Hermes — the JS engine

Concept Hermes precompiles JavaScript to bytecode at build time, so there's no parse/compile step on launch — lower startup time, lower memory, smaller footprint.
Example Switching a JSC app to Hermes drops Android TTI and steady-state memory measurably; check it's on with !!global.HermesInternal.
Problem Red flag: assuming Hermes is on without checking — on a non-Hermes engine the full JS source is parsed on every cold start, taxing low-end devices most.
Solution Hermes is the default under the New Architecture (0.76+). Profile CPU hotspots with the Hermes sampling profiler rather than reading code and guessing. “Hermes precompiles to bytecode, so there's no parse step at launch — that's the startup and memory win.”
Rendering

Re-render discipline

Concept React re-renders on state / prop / context change. React.memo only helps if the props are referentially stable.
Example A parent passes a fresh inline object every render, so a memoized child re-renders anyway:
// ❌ new object identity every render <Child style={{ margin: 8 }} onPress={() => go()} /> // ✅ stable references const style = useMemo(() => ({ margin: 8 }), []); const onPress = useCallback(() => go(), []); <Child style={style} onPress={onPress} />
Problem Red flag: a context holding a frequently-changing value re-renders every consumer, even ones reading an unrelated field — that's why Context is a poor fit for high-frequency state.
Solution Stabilize props with useMemo/useCallback, split broad contexts, and move hot state into a store with selectors so only the slice's consumers re-render. “Context is for low-frequency values like theme or locale; high-frequency state goes in a store with selectors.”
Lists

Long lists at scale

Concept Virtualization renders only visible rows; a recycling list (FlashList) reuses row views instead of mounting/unmounting them.
Example A 10k-row chat janks on a plain list but scrolls at 60fps on a recycling list with a cheap, memoized renderItem and a stable key.
Problem Red flag: using the array index as the key. On reorder/insert/delete, views get recycled onto the wrong data → visual glitches.
Solution Always use a stable unique id in keyExtractor, memoize renderItem, and provide getItemLayout for fixed-height rows. FlashList v2 auto-measures under the New Architecture (no estimatedItemSize). “I always key list items by a stable unique id, never the array index — index keys break on reorder or delete.”
Memory

Memory leaks

Concept A leak is a reference that outlives its usefulness, blocking GC. On a phone, repeated navigation churn turns a small leak into a crash.
Example Three classics — a listener, a timer, and a closure capturing a big object:
useEffect(() => { const sub = emitter.addListener('x', onX); const id = setInterval(tick, 1000); return () => { sub.remove(); clearInterval(id); }; // ✅ cleanup }, []);
Problem Red flag: subscriptions never removed, intervals never cleared, or a long-lived callback that retains a large data structure.
Solution Always return a cleanup from useEffect; clear every timer; cancel every subscription; avoid capturing large objects in callbacks that live a long time. Confirm with the memory profiler that the curve flattens. “Every subscription and timer gets a cleanup in useEffect — if I add a listener, the same effect removes it.”
Bundle

Bundle size & tree-shaking

Concept A smaller JS bundle means faster download, parse, and TTI. Dead code that survives bundling costs you on every launch.
Example Importing a whole utility library vs. one function:
// ❌ pulls the whole library import _ from 'lodash'; // ✅ only what you use (tree-shakeable) import groupBy from 'lodash/groupBy';
Problem Red flag: barrel files and side-effectful modules defeat tree-shaking, so unused code ships anyway.
Solution Import specific paths, mark packages "sideEffects": false where true, visualize the bundle to find bloat, and enable minify + shrink (R8) on Android to strip dead native/JS code. “I import specific paths, not whole libraries — barrel imports defeat tree-shaking even if I only use one function.”
Threads

Animations & the JS thread

Concept Classic RN runs your code on the JS thread and rendering on the UI thread. If JS is busy, anything driven by JS stutters.
Example A gesture-driven animation stays at 60fps because it runs on the UI thread (Reanimated worklets / useNativeDriver: true) while a list does JS work.
Problem Red flag: Animated without the native driver, or heavy layout/compute on the JS thread, drops frames during interaction.
Solution Run animations on the UI thread, set useNativeDriver: true, and push heavy compute off the critical path (defer with InteractionManager or a worklet). “Animations that must stay smooth run on the UI thread — useNativeDriver: true or a Reanimated worklet — so a busy JS thread can't drop their frames.”
Architecture

The New Architecture (Fabric · TurboModules · JSI)

Concept JSI lets JS call native synchronously (no JSON bridge); Fabric is a concurrent renderer with a C++ shadow tree; TurboModules load native modules lazily.
Example Fabric's synchronous layout is exactly what lets a modern recycling list measure items on the fly instead of needing size estimates.
Problem The legacy bridge serialized and batched every call, adding latency on rapid native interactions and blocking concurrent React.
Solution Adopt the New Architecture (default since 0.76) and prefer libraries that support it; it's the foundation the other wins build on. “JSI removed the bridge's serialization tax, Fabric added a concurrent renderer, and TurboModules made native modules lazy — together that's the New Architecture.”
Method

Profiling-first methodology

Concept Optimization without measurement is guessing. Find the proven bottleneck, fix that, re-measure.
Example The React DevTools Profiler shows which component re-rendered and why; the Hermes sampler shows CPU hotspots; native profilers show memory growth.
Problem Red flag: sprinkling memo/useMemo everywhere adds overhead and complexity while often fixing nothing real.
Solution 1) Reproduce the slowdown reliably. 2) Measure — render counts, flamegraph, or a native profiler — to find the actual hotspot. 3) Fix that specific bottleneck, nothing else. 4) Re-measure to confirm the fix worked. 5) Lock it with a check so it can't silently regress. “I don't guess at performance fixes — reproduce, measure, fix the specific hotspot, then re-measure to prove it worked.”
Concept

On-device inference — what & why

Concept The model weights live on the phone and all computation runs locally — no server round-trip. A small C++ runtime runs a model exported to a .pte binary, delegating to a hardware backend (CPU/XNNPACK by default).
Example A chat assistant runs a 1B quantized LLM fully offline via a declarative hook (useLLM), streaming tokens into the UI.
Problem Cloud inference means network latency, recurring GPU bills, and shipping users' audio/images/chats off-device.
Solution On-device inference buys privacy (data never leaves the device), offline use, no server cost, and low latency — at the price of RAM/storage budgeting. “On-device inference trades a RAM/storage budget for privacy, offline availability, and zero server cost.”
Setup

New Architecture + mandatory init

Concept The runtime ships native code, so it requires the New Architecture (Fabric + TurboModules) and a one-time initialization with a resource-fetcher adapter before any model API is called.
Example
// app entry, once, before any model hook import { initExecutorch } from 'react-native-executorch'; import { ExpoResourceFetcher } from 'react-native-executorch-expo-resource-fetcher'; initExecutorch({ resourceFetcher: ExpoResourceFetcher });
Problem Calling a model hook before init throws an "adapter not initialized" error; the old architecture is unsupported.
Solution Initialize at the app root. RN 0.76+ / modern Expo SDKs enable the New Architecture by default, so confirm it's on.
Tooling

Expo: you must use a dev build

Concept A library with native code can't run in the prebuilt Expo Go sandbox — it needs a custom development build.
Example Add expo-dev-client, then npx expo prebuild + npx expo run:ios -d (real device for iOS release), or build a dev client on EAS.
Problem Red flag: trying to test in Expo Go — it will never load the native model code, wasting hours.
Solution Commit to a dev build from day one. No config plugin is needed (it autolinks), but add the resource-fetcher adapter and push .pte into assetExts in metro.config.js if bundling models. “Any native code means a dev build, not Expo Go — I add expo-dev-client from day one.”
Loading

Model loading strategies

Concept Three ways to supply a model: bundled via require() (< 512 MB), remote URL (downloaded to the documents dir with progress), or a local file path the user provides.
Example Small classifier? Bundle it. 1 GB LLM? Download from a URL and show downloadProgress (0→1).
Problem Bundling a multi-hundred-MB .pte into the binary bloats the app and hits the bundling size limit.
Solution Prefer remote URL for large models, gate the download behind explicit user opt-in with a visible progress UI, and cache it in the documents directory. “I pick the loading strategy by model size — bundle small models, stream large ones with visible progress and user opt-in.”
Memory

Model sizing & device RAM

Concept LLMs are RAM-hungry and size scales with parameters and quantization. Rule of thumb: a 1B model ≈ 1 GB download / ~2 GB RAM.
Example Device tiers: 8 GB+ RAM → 3B models; 6 GB → 1B quantized; 4 GB → computer-vision models only.
Problem Red flag: loading a 3B model on a 4 GB device crashes the app (and the iOS Simulator can't run iOS release builds).
Solution Detect the device tier, start with a small quantized model, and upgrade only when RAM allows. Always test on physical devices. “I size the model to the device tier, not the flagship — and I only trust RAM numbers from a physical device, not the simulator.”
Lifecycle

LLM lifecycle & crash-safety

Concept A hook auto-loads its model on mount and exposes isReady, downloadProgress, isGenerating, a streaming response, and interrupt(). Only one LLM instance can be active at a time.
Example A "Stop" button calls interrupt(); token emission is batched (~10 tokens / 80ms) so very fast generation doesn't trigger a re-render storm.
Problem Red flag: unmounting or navigating away from the screen while the LLM is still generating crashes the app.
Solution Call interrupt() and wait for isGenerating === false before unmounting; bound memory with a sliding-window context strategy and cap generation length (~256 tokens for short answers). “I never unmount while the model is generating — I call interrupt() and wait for isGenerating to go false first.”
Models

Quantization

Concept Quantization lowers weight precision (e.g. SpinQuant / 8da4w / QLoRA), shrinking the model and speeding inference with minimal quality loss.
Example A base model ~3.3 GB vs. its quantized variant ~1.9 GB — about a 42% reduction — fitting devices that the base model couldn't.
Problem A full-precision model is too large/slow for phones and crashes mid-range devices.
Solution Default to quantized variants and budget tokens/sec on your oldest supported device, not just the newest flagship. “I default to the quantized variant and budget for the oldest supported device, not the newest one.”
Versioning

Pre-1.0 churn — pin everything

Concept The library is pre-1.0 and ships breaking changes most minor releases; model constants pin to specific tags so the runtime stays compatible.
Example A minor bump renamed factory APIs and hooks (e.g. ImageSegmentationSemanticSegmentation) and made init mandatory.
Problem Red flag: auto-upgrading to "latest" silently breaks the API surface and your model URLs.
Solution Pin the exact version + matching adapter, read release notes before any bump, re-test on physical iOS/Android release builds, and don't hand-edit pinned model URLs. “Pre-1.0 libraries get pinned versions, not 'latest' — I read release notes before every bump.”