All posts

September 2026 · Prakhar Bansal

React Native Crash Reporting and Analytics With One SDK

React Native Crash Reporting and Analytics With One SDK

To handle React Native crash reporting and analytics with one SDK, route everything through a single telemetry module. That module hooks React Native's global error handler, Hermes's promise rejection tracker and a React error boundary. It also exposes track() and screen() for product events. Every error, event and screen view goes into one persisted queue, so it shares one session ID, one user ID and one breadcrumb trail. Below is a complete, working version of that module (about 150 lines of TypeScript), the wiring for React Navigation, and how to test it in a release build.

The reason to do this in one place instead of two SDKs isn't tidiness. When a crash report and your analytics disagree on who the user was and what they did in the last 30 seconds, the crash report is much less useful. When both come from the same queue, the events leading up to a crash become the crash's breadcrumbs for free.

What you actually need to capture

A React Native app can fail in more places than a web app. The JavaScript layer produces four kinds of failure:

  1. Uncaught JS exceptions. These reach ErrorUtils, React Native's global handler. In release builds, a fatal one kills the app.
  2. Unhandled promise rejections. These are silent in production unless you turn on Hermes's rejection tracker. Most apps miss these entirely.
  3. Render errors inside an error boundary. The boundary catches them, so they never reach the global handler.
  4. Native crashes. These include Java/Kotlin exceptions, Objective-C/Swift crashes, NDK signals, ANRs and out-of-memory kills. JavaScript can't see these at all. More on this at the end.

On the analytics side you need custom events, screen views and user identity. Every error should carry a device context: OS, app version, build number and model.

The hard constraint is that a fatal error gives you almost no time. A fetch() started inside the crash handler won't finish before the process dies. So the module writes to disk synchronously and sends the report on the next launch. That's why it uses react-native-mmkv rather than AsyncStorage, which is asynchronous.

Install the dependencies

npm install react-native-mmkv@^3 react-native-device-info
cd ios && pod install && cd ..

MMKV v3 requires the New Architecture, which is the default from React Native 0.76. If you're on the old architecture, pin react-native-mmkv@^2. The API used below is the same.

The telemetry module

Create src/telemetry.ts:

import { AppState, Platform } from 'react-native';
import { MMKV } from 'react-native-mmkv';
import DeviceInfo from 'react-native-device-info';

type Breadcrumb = { ts: number; category: string; message: string };

export type TelemetryEvent = {
  type: 'event' | 'screen' | 'error';
  name: string;
  ts: number;
  sessionId: string;
  userId?: string;
  props?: Record<string, unknown>;
  error?: { message: string; stack?: string; fatal: boolean };
  breadcrumbs?: Breadcrumb[];
};

type Config = { endpoint: string; apiKey: string; flushIntervalMs?: number };

const storage = new MMKV({ id: 'telemetry' });
const QUEUE_KEY = 'queue';
const MAX_QUEUE = 500;
const MAX_BREADCRUMBS = 30;
const BATCH_SIZE = 50;

const sessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const breadcrumbs: Breadcrumb[] = [];
let queue: TelemetryEvent[] = [];
let config: Config | null = null;
let userId: string | undefined;
let flushing = false;
let initialized = false;

const context = {
  platform: Platform.OS,
  osVersion: String(Platform.Version),
  appVersion: DeviceInfo.getVersion(),
  buildNumber: DeviceInfo.getBuildNumber(),
  model: DeviceInfo.getModel(),
};

function loadQueue(): TelemetryEvent[] {
  const raw = storage.getString(QUEUE_KEY);
  if (!raw) return [];
  try {
    return JSON.parse(raw);
  } catch {
    return [];
  }
}

function persist() {
  // Synchronous write: safe to call from a fatal crash handler.
  storage.set(QUEUE_KEY, JSON.stringify(queue));
}

function push(event: Omit<TelemetryEvent, 'ts' | 'sessionId' | 'userId'>) {
  queue.push({ ...event, ts: Date.now(), sessionId, userId });
  if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE);
  persist();
}

export function addBreadcrumb(category: string, message: string) {
  breadcrumbs.push({ ts: Date.now(), category, message });
  if (breadcrumbs.length > MAX_BREADCRUMBS) breadcrumbs.shift();
}

export function track(name: string, props?: Record<string, unknown>) {
  addBreadcrumb('event', name);
  push({ type: 'event', name, props });
}

export function screen(name: string, props?: Record<string, unknown>) {
  addBreadcrumb('navigation', name);
  push({ type: 'screen', name, props });
}

export function identify(id: string | undefined) {
  userId = id;
}

export function captureError(
  error: unknown,
  fatal = false,
  props?: Record<string, unknown>,
) {
  const err = error instanceof Error ? error : new Error(String(error));
  push({
    type: 'error',
    name: err.name || 'Error',
    props,
    error: { message: err.message, stack: err.stack, fatal },
    breadcrumbs: [...breadcrumbs],
  });
}

export async function flush() {
  if (!config || flushing || queue.length === 0) return;
  flushing = true;
  const batch = queue.slice(0, BATCH_SIZE);
  try {
    const res = await fetch(config.endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${config.apiKey}`,
      },
      body: JSON.stringify({ context, events: batch }),
    });
    if (res.ok) {
      const sent = new Set(batch);
      queue = queue.filter((e) => !sent.has(e));
      persist();
    }
  } catch {
    // Offline or server error: events stay queued for the next attempt.
  } finally {
    flushing = false;
  }
}

function installErrorHandlers() {
  const defaultHandler = ErrorUtils.getGlobalHandler();
  ErrorUtils.setGlobalHandler((error, isFatal) => {
    captureError(error, !!isFatal);
    defaultHandler(error, isFatal);
  });

  // Hermes only tracks unhandled rejections in dev by default.
  const hermes = (global as any).HermesInternal;
  if (!__DEV__ && hermes?.enablePromiseRejectionTracker) {
    hermes.enablePromiseRejectionTracker({
      allRejections: true,
      onUnhandled: (_id: number, rejection: unknown) =>
        captureError(rejection, false, { unhandledRejection: true }),
      onHandled: () => {},
    });
  }
}

export function init(cfg: Config) {
  if (initialized) return;
  initialized = true;
  config = cfg;
  queue = loadQueue(); // includes any crash saved by the previous session
  installErrorHandlers();

  AppState.addEventListener('change', (state) => {
    if (state !== 'active') flush();
  });
  setInterval(flush, cfg.flushIntervalMs ?? 30_000);
  flush();
}

A few design choices are worth explaining.

The default handler still runs. Swallowing errors would hide the red screen in development and leave the app in an undefined state in production. You record the error, then let React Native do what it normally does.

Breadcrumbs are free. Every track() and screen() call also becomes a breadcrumb. When a crash is captured, it carries a snapshot of the last 30 things the user did. You don't have to instrument anything twice.

Queue writes happen before network calls. If the app dies mid-flush, nothing is lost. If the flush succeeds, only the events that were actually sent are removed.

Initialize it before anything else

Error handlers installed inside a component can miss errors thrown during module evaluation. Initialize in index.js, before registerComponent:

import { AppRegistry } from 'react-native';
import { init } from './src/telemetry';
import App from './App';
import { name as appName } from './app.json';

init({
  endpoint: 'https://your-ingest-endpoint.example.com/v1/events',
  apiKey: 'YOUR_PUBLIC_INGEST_KEY',
});

AppRegistry.registerComponent(appName, () => App);

Use a write-only ingest key here. Anything shipped in the bundle can be extracted.

Catch render errors with an error boundary

When an error boundary catches a render error, the error stops there and never reaches ErrorUtils. The boundary needs to report it itself:

import React from 'react';
import { Button, Text, View } from 'react-native';
import { captureError } from './telemetry';

type State = { hasError: boolean };

export class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  State
> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    captureError(error, false, { componentStack: info.componentStack });
  }

  render() {
    if (this.state.hasError) {
      return (
        <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
          <Text>Something went wrong.</Text>
          <Button title="Try again" onPress={() => this.setState({ hasError: false })} />
        </View>
      );
    }
    return this.props.children;
  }
}

The component stack is often more useful than the JS stack for render bugs, because it tells you which screen and component tree were mounted.

Track screens automatically with React Navigation

This follows React Navigation's recommended pattern for screen tracking. It fires once on mount and then on every route change:

import React, { useRef } from 'react';
import {
  NavigationContainer,
  useNavigationContainerRef,
} from '@react-navigation/native';
import { ErrorBoundary } from './src/ErrorBoundary';
import { screen } from './src/telemetry';
import RootStack from './src/RootStack';

export default function App() {
  const navigationRef = useNavigationContainerRef();
  const routeNameRef = useRef<string | undefined>(undefined);

  return (
    <ErrorBoundary>
      <NavigationContainer
        ref={navigationRef}
        onReady={() => {
          const name = navigationRef.getCurrentRoute()?.name;
          routeNameRef.current = name;
          if (name) screen(name);
        }}
        onStateChange={() => {
          const name = navigationRef.getCurrentRoute()?.name;
          if (name && name !== routeNameRef.current) screen(name);
          routeNameRef.current = name;
        }}
      >
        <RootStack />
      </NavigationContainer>
    </ErrorBoundary>
  );
}

Custom events and identity then look like this anywhere in your app:

import { identify, track } from './src/telemetry';

identify(user.id);
track('checkout_started', { cartValue: 49.99, items: 3 });

Testing your React Native crash reporting in a release build

Development builds behave differently. LogBox intercepts errors, the promise tracker is already owned by React Native, and nothing is minified. Always verify in release mode.

Add a temporary debug screen with three buttons:

import React, { useState } from 'react';
import { Button, View } from 'react-native';

export function CrashTestScreen() {
  const [explode, setExplode] = useState(false);
  if (explode) throw new Error('Render crash test');

  return (
    <View style={{ padding: 24, gap: 12 }}>
      <Button
        title="Fatal JS error"
        onPress={() => {
          throw new Error('Fatal crash test');
        }}
      />
      <Button
        title="Unhandled rejection"
        onPress={() => {
          Promise.reject(new Error('Rejection test'));
        }}
      />
      <Button title="Render error" onPress={() => setExplode(true)} />
    </View>
  );
}

Then run a release build:

npx react-native run-android --mode release
npx react-native run-ios --mode Release

Tap "Fatal JS error". The app should close. Reopen it, and the saved crash should arrive at your endpoint on launch, with the breadcrumbs from before the crash attached. If it doesn't, check that init() runs in index.js and that MMKV is linked. On iOS, the pod install is the usual culprit.

Make stack traces readable

Release stacks point into a minified Hermes bundle, so you'll see things like index.android.bundle:1:84213. To get file and line numbers back, keep the source map for every release and key it by appVersion + buildNumber. Both are already attached to every event as part of context.

On Android, the build writes a composed Hermes map to android/app/build/generated/sourcemaps/react/release/index.android.bundle.map. On iOS, set SOURCEMAP_FILE in the "Bundle React Native code and images" build phase:

export SOURCEMAP_FILE="$DERIVED_FILE_DIR/main.jsbundle.map"

Upload the map in CI after each release build. Your backend can then symbolicate incoming stacks with Mozilla's source-map package. Without this step, your crash data is technically complete and practically unreadable.

What this module can't do: native crashes

Everything above lives in JavaScript, so it only sees failures that pass through the JS runtime. A null pointer in a native module, a crash in a third-party SDK, an ANR on Android or the OS killing your app for memory never touch ErrorUtils. Capturing those requires native signal and exception handlers on both platforms. Those handlers have to write a report to disk from inside a crashing process. Writing them well is genuinely hard, which is why most teams reach for a dedicated SDK at this point. If you're weighing the usual default, we've written up how the options compare to Crashlytics. One common drawback there is that crashes and analytics still end up in separate tools with separate user models.

That gap is what we built Nohmo for. It's one SDK that covers the JS error handling in this post plus native crash capture, analytics, breadcrumbs and source map symbolication, with a single shared session and user. The setup guide in our docs takes a few minutes. If you'd rather own the pipeline yourself, the module above is a solid foundation. Add a native crash handler and a symbolication step, and you have the core of a real crash reporting system.