πŸ“± React Native at Scale: Lessons from 100K+ Users



This content originally appeared on DEV Community and was authored by Nilava Chowdhury

Engineering a React Native app that handles millions of daily transactions

Introduction

When we launched udChalo’s mobile app for armed forces personnel, we knew we were building for a unique audience with specific needs: reliability in low-connectivity areas, security for sensitive data, and performance that matches native apps. This post shares our journey from zero to 100K+ users in 6 months, achieving a 4.5-star rating while processing millions of flight bookings.

The Scale Challenge

Requirements and Constraints

const appRequirements = {
  scale: {
    targetUsers: 100000,
    dailyActiveUsers: 50000,
    peakConcurrent: 10000,
    transactionsPerDay: 100000,
    dataSync: '5GB daily'
  },

  performance: {
    coldStart: '<2 seconds',
    navigation: '<100ms',
    apiResponse: '<500ms',
    offlineCapability: 'Full feature parity',
    memoryUsage: '<150MB'
  },

  userEnvironment: {
    devices: '70% Android, 30% iOS',
    androidVersions: '60% < Android 8',
    networkQuality: '40% on 2G/3G',
    locations: 'Remote military bases',
    storage: 'Limited (2-4GB available)'
  },

  security: {
    encryption: 'Military-grade',
    authentication: 'Biometric + PIN',
    dataResidency: 'India only',
    compliance: 'Government standards'
  }
};

Architecture Decisions

React Native Architecture

// App Architecture
const architecture = {
  core: {
    version: 'React Native 0.71',
    newArchitecture: true,  // Fabric + TurboModules
    hermes: true,  // For better performance
    reanimated: 3,  // For 60fps animations
  },

  stateManagement: {
    global: 'Redux Toolkit + RTK Query',
    local: 'React Hook Form',
    persistence: 'Redux Persist + MMKV',

  },

  navigation: {
    library: 'React Navigation 6',
    structure: 'Tab + Stack hybrid',
    deepLinking: true,

  },

  native: {
    modules: [
      'Biometric Authentication',
      'Secure Storage',
      'Network Detection',
      'Background Sync',
      'Push Notifications'
    ],

  }
};

// Folder Structure for Scale
const projectStructure = `
src/
β”œβ”€β”€ components/          # Shared components
β”‚   β”œβ”€β”€ atoms/          # Basic building blocks
β”‚   β”œβ”€β”€ molecules/      # Composite components
β”‚   └── organisms/      # Complex components
β”œβ”€β”€ screens/            # Screen components
β”œβ”€β”€ navigation/         # Navigation configuration
β”œβ”€β”€ services/           # API and external services
β”œβ”€β”€ store/              # Redux store and slices
β”œβ”€β”€ utils/              # Utility functions
β”œβ”€β”€ hooks/              # Custom hooks
β”œβ”€β”€ native/             # Native module bridges
└── constants/          # App constants
`;

Performance Optimization Strategy

// Performance-First Component Design
import React, { memo, useMemo, useCallback } from 'react';
import { FlatList, View, Text } from 'react-native';
import FastImage from 'react-native-fast-image';

interface FlightListProps {
  flights: Flight[];
  onSelect: (flight: Flight) => void;
}

// Optimized Flight List Component
export const FlightList = memo<FlightListProps>(({ flights, onSelect }) => {
  // Memoize expensive calculations
  const sortedFlights = useMemo(() => 
    flights.sort((a, b) => a.price - b.price),
    [flights]
  );

  // Optimize callbacks
  const renderItem = useCallback(({ item }: { item: Flight }) => (
    <FlightCard flight={item} onPress={() => onSelect(item)} />
  ), [onSelect]);

  const keyExtractor = useCallback((item: Flight) => item.id, []);

  const getItemLayout = useCallback((data: any, index: number) => ({
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  }), []);

  return (
    <FlatList
      data={sortedFlights}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      getItemLayout={getItemLayout}

      // Performance optimizations
      removeClippedSubviews={true}
      maxToRenderPerBatch={10}
      updateCellsBatchingPeriod={50}
      initialNumToRender={10}
      windowSize={10}

      // Memory optimization
      maintainVisibleContentPosition={{
        minIndexForVisible: 0,
      }}
    />
  );
});

// Optimized Image Component
const FlightAirlineLogo = memo(({ airline }: { airline: string }) => {
  const imageSource = useMemo(() => ({
    uri: getAirlineLogoUrl(airline),
    priority: FastImage.priority.normal,
    cache: FastImage.cacheControl.immutable,
  }), [airline]);

  return (
    <FastImage
      style={styles.airlineLogo}
      source={imageSource}
      resizeMode={FastImage.resizeMode.contain}
    />
  );
});

Native Module Implementation

Biometric Authentication Module

// Android Native Module - BiometricAuthModule.java
package com.udchalo.biometric;

import androidx.biometric.BiometricPrompt;
import androidx.fragment.app.FragmentActivity;
import com.facebook.react.bridge.*;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

public class BiometricAuthModule extends ReactContextBaseJavaModule {
    private final ReactApplicationContext reactContext;
    private final Executor executor = Executors.newSingleThreadExecutor();

    public BiometricAuthModule(ReactApplicationContext reactContext) {
        super(reactContext);
        this.reactContext = reactContext;
    }

    @Override
    public String getName() {
        return "BiometricAuth";
    }

    @ReactMethod
    public void authenticate(String reason, Promise promise) {
        FragmentActivity activity = (FragmentActivity) getCurrentActivity();

        if (activity == null) {
            promise.reject("E_ACTIVITY_NULL", "Activity is null");
            return;
        }

        activity.runOnUiThread(() -> {
            BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder()
                .setTitle("Authentication Required")
                .setSubtitle(reason)
                .setNegativeButtonText("Cancel")
                .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
                .build();

            BiometricPrompt biometricPrompt = new BiometricPrompt(
                activity,
                executor,
                new BiometricPrompt.AuthenticationCallback() {
                    @Override
                    public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
                        promise.resolve(true);
                    }

                    @Override
                    public void onAuthenticationError(int errorCode, CharSequence errString) {
                        promise.reject("E_AUTH_FAILED", errString.toString());
                    }

                    @Override
                    public void onAuthenticationFailed() {
                        promise.reject("E_AUTH_FAILED", "Authentication failed");
                    }
                }
            );

            biometricPrompt.authenticate(promptInfo);
        });
    }

    @ReactMethod
    public void isAvailable(Promise promise) {
        BiometricManager biometricManager = BiometricManager.from(reactContext);

        switch (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
            case BiometricManager.BIOMETRIC_SUCCESS:
                promise.resolve(true);
                break;
            default:
                promise.resolve(false);
                break;
        }
    }
}
// iOS Native Module - BiometricAuth.swift
import LocalAuthentication
import React

@objc(BiometricAuth)
class BiometricAuth: NSObject {

    @objc(authenticate:resolver:rejecter:)
    func authenticate(reason: String,
                     resolver: @escaping RCTPromiseResolveBlock,
                     rejecter: @escaping RCTPromiseRejectBlock) {

        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            rejecter("E_BIOMETRIC_NOT_AVAILABLE", "Biometric authentication not available", error)
            return
        }

        context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                             localizedReason: reason) { success, error in
            DispatchQueue.main.async {
                if success {
                    resolver(true)
                } else {
                    rejecter("E_AUTH_FAILED", "Authentication failed", error)
                }
            }
        }
    }

    @objc(isAvailable:rejecter:)
    func isAvailable(resolver: RCTPromiseResolveBlock,
                    rejecter: RCTPromiseRejectBlock) {
        let context = LAContext()
        var error: NSError?

        let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
        resolver(available)
    }
}

Background Sync Implementation

// Background Sync Manager
import BackgroundFetch from 'react-native-background-fetch';
import NetInfo from '@react-native-community/netinfo';
import { MMKV } from 'react-native-mmkv';

class BackgroundSyncManager {
  private storage = new MMKV();
  private syncQueue: SyncTask[] = [];

  async initialize() {
    await BackgroundFetch.configure({
      minimumFetchInterval: 15,  // 15 minutes
      forceAlarmManager: false,
      stopOnTerminate: false,
      startOnBoot: true,
      enableHeadless: true,
      requiresBatteryNotLow: false,
      requiresCharging: false,
      requiresStorageNotLow: false,
      requiresDeviceIdle: false,
      requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY
    }, async (taskId) => {
      await this.performSync();
      BackgroundFetch.finish(taskId);
    }, (error) => {
      console.error('Background fetch failed:', error);
    });

    // Check for pending sync on app launch
    await this.checkPendingSync();
  }

  async queueForSync(data: any, type: SyncType) {
    const task: SyncTask = {
      id: generateUUID(),
      type,
      data,
      timestamp: Date.now(),
      attempts: 0,
      status: 'pending'
    };

    // Store in persistent queue
    const queue = this.getSyncQueue();
    queue.push(task);
    this.storage.set('syncQueue', JSON.stringify(queue));

    // Try immediate sync if online
    const netInfo = await NetInfo.fetch();
    if (netInfo.isConnected) {
      await this.performSync();
    }
  }

  async performSync() {
    const queue = this.getSyncQueue();
    const netInfo = await NetInfo.fetch();

    if (!netInfo.isConnected || queue.length === 0) {
      return;
    }

    const pendingTasks = queue.filter(task => task.status === 'pending');

    for (const task of pendingTasks) {
      try {
        await this.syncTask(task);
        task.status = 'completed';
      } catch (error) {
        task.attempts++;
        task.lastError = error.message;

        if (task.attempts >= 3) {
          task.status = 'failed';
        }
      }
    }

    // Update queue
    this.storage.set('syncQueue', JSON.stringify(queue));

    // Clean old completed tasks
    this.cleanupQueue();
  }

  private async syncTask(task: SyncTask) {
    switch (task.type) {
      case 'booking':
        return await this.syncBooking(task.data);
      case 'profile':
        return await this.syncProfile(task.data);
      case 'preferences':
        return await this.syncPreferences(task.data);
      default:
        throw new Error(`Unknown sync type: ${task.type}`);
    }
  }

  private getSyncQueue(): SyncTask[] {
    const queueString = this.storage.getString('syncQueue');
    return queueString ? JSON.parse(queueString) : [];
  }
}

State Management at Scale

Redux Toolkit Setup

// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { persistStore, persistReducer } from 'redux-persist';
import { MMKV } from 'react-native-mmkv';

// Custom MMKV storage adapter for Redux Persist
const storage = new MMKV();
const MMKVStorage = {
  setItem: (key: string, value: string) => {
    storage.set(key, value);
    return Promise.resolve(true);
  },
  getItem: (key: string) => {
    const value = storage.getString(key);
    return Promise.resolve(value);
  },
  removeItem: (key: string) => {
    storage.delete(key);
    return Promise.resolve();
  },
};

// RTK Query API
const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: Config.API_BASE_URL,
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token;
      if (token) {
        headers.set('authorization', `Bearer ${token}`);
      }
      return headers;
    },
  }),
  tagTypes: ['Flight', 'Booking', 'User'],
  endpoints: (builder) => ({
    searchFlights: builder.query<Flight[], FlightSearchParams>({
      query: (params) => ({
        url: '/flights/search',
        params,
      }),
      providesTags: ['Flight'],
      // Cache for 5 minutes
      keepUnusedDataFor: 300,
    }),
    createBooking: builder.mutation<Booking, CreateBookingDto>({
      query: (booking) => ({
        url: '/bookings',
        method: 'POST',
        body: booking,
      }),
      invalidatesTags: ['Booking'],
      // Optimistic update
      async onQueryStarted(booking, { dispatch, queryFulfilled }) {
        const patchResult = dispatch(
          apiSlice.util.updateQueryData('getUserBookings', undefined, (draft) => {
            draft.unshift(booking as any);
          })
        );
        try {
          await queryFulfilled;
        } catch {
          patchResult.undo();
        }
      },
    }),
  }),
});

// Configure store with persistence
const persistConfig = {
  key: 'root',
  storage: MMKVStorage,
  whitelist: ['auth', 'user', 'preferences'],
  blacklist: ['api'], // Don't persist API cache
};

const rootReducer = combineReducers({
  [apiSlice.reducerPath]: apiSlice.reducer,
  auth: authSlice.reducer,
  user: userSlice.reducer,
  preferences: preferencesSlice.reducer,
});

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }).concat(apiSlice.middleware),
});

setupListeners(store.dispatch);
export const persistor = persistStore(store);

Performance Monitoring

Custom Performance Metrics

// Performance Monitor
import performance from 'react-native-performance';
import analytics from '@react-native-firebase/analytics';

class PerformanceMonitor {
  private marks = new Map<string, number>();
  private measures = new Map<string, number[]>();

  startMark(name: string) {
    this.marks.set(name, performance.now());
  }

  endMark(name: string, customProperties?: Record<string, any>) {
    const startTime = this.marks.get(name);
    if (!startTime) return;

    const duration = performance.now() - startTime;

    // Store measure
    if (!this.measures.has(name)) {
      this.measures.set(name, []);
    }
    this.measures.get(name)!.push(duration);

    // Send to analytics if significant
    if (duration > this.getThreshold(name)) {
      analytics().logEvent('performance_metric', {
        metric_name: name,
        duration,
        ...customProperties,
      });
    }

    // Clean up
    this.marks.delete(name);
  }

  private getThreshold(name: string): number {
    const thresholds: Record<string, number> = {
      app_launch: 2000,
      screen_transition: 500,
      api_call: 1000,
      image_load: 500,
      list_render: 100,
    };

    return thresholds[name] || 1000;
  }

  reportMetrics() {
    const report: Record<string, any> = {};

    this.measures.forEach((durations, name) => {
      report[name] = {
        avg: durations.reduce((a, b) => a + b, 0) / durations.length,
        min: Math.min(...durations),
        max: Math.max(...durations),
        p95: this.calculatePercentile(durations, 0.95),
        count: durations.length,
      };
    });

    // Send to monitoring service
    analytics().logEvent('performance_report', report);

    // Clear old data
    this.measures.clear();
  }

  private calculatePercentile(values: number[], percentile: number): number {
    const sorted = values.sort((a, b) => a - b);
    const index = Math.ceil(sorted.length * percentile) - 1;
    return sorted[index];
  }
}

Offline-First Architecture

Offline Data Sync

// Offline Manager
import { MMKV } from 'react-native-mmkv';
import NetInfo from '@react-native-community/netinfo';

class OfflineManager {
  private storage = new MMKV({ id: 'offline-data' });
  private syncInProgress = false;

  async saveOfflineData(key: string, data: any) {
    const offlineData = {
      data,
      timestamp: Date.now(),
      synced: false,
    };

    this.storage.set(key, JSON.stringify(offlineData));

    // Queue for sync
    await this.queueForSync(key);
  }

  async getOfflineData(key: string): Promise<any> {
    const stored = this.storage.getString(key);
    if (!stored) return null;

    const { data, timestamp, synced } = JSON.parse(stored);

    // Check if data is stale
    const isStale = Date.now() - timestamp > 24 * 60 * 60 * 1000; // 24 hours

    if (isStale && !synced) {
      // Try to sync
      await this.syncSingleItem(key);
    }

    return data;
  }

  async syncOfflineData() {
    if (this.syncInProgress) return;

    const netInfo = await NetInfo.fetch();
    if (!netInfo.isConnected) return;

    this.syncInProgress = true;

    try {
      const allKeys = this.storage.getAllKeys();
      const unsyncedKeys = allKeys.filter(key => {
        const data = this.storage.getString(key);
        if (!data) return false;
        const parsed = JSON.parse(data);
        return !parsed.synced;
      });

      for (const key of unsyncedKeys) {
        await this.syncSingleItem(key);
      }
    } finally {
      this.syncInProgress = false;
    }
  }

  private async syncSingleItem(key: string) {
    const stored = this.storage.getString(key);
    if (!stored) return;

    const { data, timestamp } = JSON.parse(stored);

    try {
      // Determine sync endpoint based on key pattern
      const endpoint = this.getEndpointForKey(key);

      const response = await fetch(endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          data,
          clientTimestamp: timestamp,
        }),
      });

      if (response.ok) {
        // Mark as synced
        const updated = {
          data,
          timestamp,
          synced: true,
          syncedAt: Date.now(),
        };
        this.storage.set(key, JSON.stringify(updated));
      }
    } catch (error) {
      console.error(`Failed to sync ${key}:`, error);
    }
  }
}

Crash Reporting and Analytics

Crash Handler Setup

// Crash Reporter
import crashlytics from '@react-native-firebase/crashlytics';
import { ErrorBoundary } from 'react-error-boundary';

// Global error handler
ErrorUtils.setGlobalHandler((error: Error, isFatal: boolean) => {
  // Log to Crashlytics
  crashlytics().recordError(error, {
    isFatal,
    timestamp: Date.now(),
    userId: getCurrentUserId(),
    sessionId: getSessionId(),
  });

  // Log locally for debugging
  if (__DEV__) {
    console.error('Global error:', error);
  }

  // Show user-friendly error screen for fatal errors
  if (isFatal) {
    showErrorScreen(error);
  }
});

// React Error Boundary
function AppErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, errorInfo) => {
        crashlytics().log('React error boundary triggered');
        crashlytics().recordError(error, {
          errorBoundary: true,
          componentStack: errorInfo.componentStack,
        });
      }}
      onReset={() => {
        // Clear cache and restart app
        clearAppCache();
        RNRestart.Restart();
      }}
    >
      {children}
    </ErrorBoundary>
  );
}

// Custom crash reporter for specific scenarios
class CrashReporter {
  static logError(error: Error, context?: Record<string, any>) {
    const enrichedContext = {
      ...context,
      timestamp: Date.now(),
      appVersion: DeviceInfo.getVersion(),
      buildNumber: DeviceInfo.getBuildNumber(),
      device: {
        brand: DeviceInfo.getBrand(),
        model: DeviceInfo.getModel(),
        os: Platform.OS,
        osVersion: DeviceInfo.getSystemVersion(),
      },
      memory: {
        used: DeviceInfo.getUsedMemorySync(),
        total: DeviceInfo.getTotalMemorySync(),
      },
      network: getNetworkState(),
    };

    crashlytics().recordError(error, enrichedContext);

    // Send to custom monitoring
    if (shouldSendToCustomMonitoring(error)) {
      sendToCustomMonitoring(error, enrichedContext);
    }
  }
}

Testing Strategy

E2E Testing with Detox

// e2e/bookingFlow.test.js
describe('Booking Flow', () => {
  beforeAll(async () => {
    await device.launchApp({
      newInstance: true,
      permissions: { notifications: 'YES', location: 'always' },
    });
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should complete flight booking successfully', async () => {
    // Login
    await element(by.id('email-input')).typeText('test@udchalo.com');
    await element(by.id('password-input')).typeText('Test@1234');
    await element(by.id('login-button')).tap();

    // Search flights
    await waitFor(element(by.id('search-screen')))
      .toBeVisible()
      .withTimeout(5000);

    await element(by.id('from-airport')).tap();
    await element(by.text('Delhi (DEL)')).tap();

    await element(by.id('to-airport')).tap();
    await element(by.text('Mumbai (BOM)')).tap();

    await element(by.id('departure-date')).tap();
    await element(by.text('15')).tap();

    await element(by.id('search-button')).tap();

    // Select flight
    await waitFor(element(by.id('flight-list')))
      .toBeVisible()
      .withTimeout(10000);

    await element(by.id('flight-item-0')).tap();

    // Passenger details
    await element(by.id('passenger-name')).typeText('John Doe');
    await element(by.id('passenger-phone')).typeText('9876543210');

    await element(by.id('continue-button')).tap();

    // Payment
    await element(by.id('payment-method-upi')).tap();
    await element(by.id('upi-id')).typeText('test@upi');

    await element(by.id('pay-button')).tap();

    // Verify booking success
    await waitFor(element(by.id('booking-success')))
      .toBeVisible()
      .withTimeout(15000);

    await expect(element(by.id('booking-id'))).toBeVisible();
  });

  it('should handle network failure gracefully', async () => {
    // Disable network
    await device.setURLBlacklist(['.*']);

    // Try to search flights
    await element(by.id('search-button')).tap();

    // Should show offline message
    await expect(element(by.text('You are offline'))).toBeVisible();

    // Re-enable network
    await device.clearURLBlacklist();

    // Should auto-retry
    await waitFor(element(by.id('flight-list')))
      .toBeVisible()
      .withTimeout(10000);
  });
});

Production Metrics and Results

const productionMetrics = {
  scale: {
    totalUsers: 127000,
    dailyActiveUsers: 52000,
    monthlyActiveUsers: 98000,
    peakConcurrentUsers: 12500,
    dailyTransactions: 85000,
    totalBookings: 15000000  // Total value
  },

  performance: {
    appStartTime: {
      cold: 1.8,  // seconds
      warm: 0.6
    },
    screenTransition: 92,  // milliseconds
    apiResponseTime: {
      p50: 145,
      p95: 380,
      p99: 520
    },
    crashFreeRate: 99.7,  // percentage
    anr: 0.02  // percentage
  },

  userEngagement: {
    avgSessionLength: '8:30',  // minutes
    dailySessions: 3.2,
    retention: {
      day1: 82,
      day7: 68,
      day30: 54
    },
    appStoreRating: 4.5,
    playStoreRating: 4.4
  },

  technical: {
    bundleSize: {
      android: '28MB',
      ios: '32MB'
    },
    memoryUsage: {
      avg: 95,  // MB
      peak: 142
    },
    batteryImpact: 'Low',
    offlineCapability: '100% core features'
  }
};

Key Learnings

  1. Performance is a Feature: Users expect native-like performance from React Native
  2. Offline-First is Essential: Military bases often have poor connectivity
  3. Native Modules are Powerful: Don’t hesitate to write native code when needed
  4. State Management Matters: Redux Toolkit + RTK Query simplified complex state
  5. Monitoring is Crucial: Can’t improve what you don’t measure

Conclusion

Building a React Native app that scales to 100K+ users taught us that success lies in making the right architectural decisions early, obsessing over performance, and never compromising on user experience. The combination of React Native’s cross-platform benefits with strategic native optimizations enabled us to deliver an app that serves critical needs of armed forces personnel reliably, even in the most challenging conditions.


This content originally appeared on DEV Community and was authored by Nilava Chowdhury