Mobile Apps

Debugging native crashes when your team only writes JavaScript

Read and symbolize native crash reports from iOS and Android, trace the common culprits behind EXC_BAD_ACCESS and SIGABRT, and fix them without writing native code.

Mohammed Saqib9 min read
A broken laptop screen displayed with colorful glitch being held by a person.
Photo by Beyzanur K. on Pexels · Pexels License

A JavaScript error in React Native is a caught exception with a red box and a stack trace you can read. A native crash is a SIGABRT that kills the whole process with no JS stack at all, just a list of hex addresses. When your team only writes TypeScript, the first time you see an EXC_BAD_ACCESS in a release build is usually the moment you realize you don't actually know what your app is made of.

Why JavaScript-only teams see native crashes at all

Your React Native app is not a JavaScript app. It's a thin JS layer running inside a native shell: the Hermes or JavaScriptCore engine, a bunch of compiled Objective-C and Java modules, and a few platform views that UIKit and Android's View system own. A JS error becomes a thrown exception that the runtime catches and turns into a red box. A native fault has no such safety net — it segfaults, the process dies, and the operating system writes a crash report.

The hot zone is the boundary between the two worlds. Every time you call a TurboModule, every JSI call that passes a string or a host object into native code, and every native event handler that marshals data back into JS, you're executing code that can violate memory safety. The JS engine can't catch a bad pointer dereference in a C++ module; it can only watch the process die. The Adopting React Native New Architecture: Fabric and TurboModules post covers the boundary mechanics in more detail, but the key fact is this: the more data you push across that boundary, the more surface area you have for a crash that will never produce a JS stack trace.

Flutter has the same shape. Dart exceptions are handled by the Dart VM, but platform channels and the Skia or Impeller engine run outside it. A bad message on a platform channel, or a rendering bug in the engine, produces a native crash report with no Dart frames in it.

The anatomy of a native crash report

Read the exception type first. EXC_BAD_ACCESS means a memory fault: your process touched an address it didn't own, usually a dangling pointer or an over-released Objective-C object. SIGABRT means an uncaught Objective-C/Swift exception or a failed assert() — the process chose to die rather than continue. SIGSEGV and SIGBUS are both bad memory accesses with slightly different flavors: invalid page access versus a misaligned or non-existent physical address.

Next, find the faulting thread. The report marks one thread as "crashed" or "faulting". Every other thread in the report is just suspended mid-state and is almost always a distraction. The frames on the crashed thread are the only ones that matter for the initial diagnosis.

The frame list itself is useless until symbolicated. Without a dSYM file on iOS or a mapping file on Android, you're looking at raw hexadecimal addresses. The registers and the binary images section matter because they let the symbolication tool know which version of which library each address belongs to. That's why the same crash on two different app versions can symbolicate correctly for one and not the other.

Symbolication: turning hex addresses into function names

Symbolication is the process of mapping those hex addresses back to function names. On iOS, you need the dSYM files generated at build time. Upload them to your crash reporter as part of your release pipeline — if you're using EAS Build, make sure the build profile is configured to keep CI reproducible and that the dSYM upload step runs after the archive step. Locally, symbolicatecrash can handle a .crash file if you have the matching dSYM and the app binary:

xcrun symbolicatecrash --dsym path/to/App.app.dSYM --input crash.crash --output symbolicated.crash

On Android, R8 or ProGuard obfuscates Java and Kotlin names, so you need the mapping.txt file for that build. Native C++ crashes in React Native's own code or in a native module need the unstripped .so files. The Android NDK ships ndk-stack, which can translate a logcat stack trace if you point it at the symbol files:

adb logcat -d | ndk-stack -sym path/to/obj/local/arm64-v8a

For React Native, you also need to upload the JS source maps. A native crash report that has been symbolicated back to a C++ function name is only half the story; correlating that frame with the JS bundle that was executing at the time tells you which user action triggered the native call. Enable debug symbols in your release build and make sure your crash reporter receives the source maps in the same upload step as the native symbols. Apple's documentation on collecting crash reports and Android's ndk-stack guide cover the platform-specific details.

Choosing a crash reporter: Sentry, Crashlytics, or self-hosted

For a team that doesn't write native code, the crash reporter's job is to do the symbolication and grouping for you. The trade-offs are real.

Approach Native symbolication JS source maps Cost model Best for
Sentry Automatic with dSYM/NDK upload First-class, auto-upload via sentry-cli Scales with event volume Teams that want one tool for JS and native
Crashlytics Automatic, but setup is Firebase-centric Supported, but source-map upload is manual and less polished Free Teams already on Firebase or Play/App Store workflows
Self-hosted (Sentry, GlitchTip) You own the upload pipeline You own the upload pipeline Infrastructure cost only Teams with strict data-privacy requirements

The decision rule for a JS-only team is simple: use a managed service that auto-symbolicates and groups by native frame. Sentry's React Native SDK is the strongest end-to-end story because it uploads source maps and dSYMs in one command and correlates the JS and native stacks in a single issue. Crashlytics is free and deeply integrated with Play Console and App Store Connect, but the react native crash reporting setup requires more manual steps for source maps, and grouping is less reliable when the crash is purely native.

Do not build a symbolication pipeline yourself. You will spend a week matching dSYM UUIDs to builds and still miss the one release that matters. If you need to self-host for privacy reasons, run Sentry's self-hosted option and treat symbol upload as part of the build, not an afterthought.

The usual suspects: memory, native modules, and release-only crashes

Most native crashes fall into a few buckets. EXC_BAD_ACCESS and jetsam/OOM kills usually come from memory pressure: decoding a 4000px image into an Image component, accumulating data in a JS array until the native side can't keep up, or a native module that holds onto a reference longer than it should. The fix is often in JS — resize images before rendering, paginate lists, and avoid loading entire database tables into a single object.

SIGABRT usually means an uncaught exception thrown by a native module. The crash report's console log will have the last few lines before the abort, and they often name the exact reason: "attempt to insert nil object", "index out of bounds", "unrecognized selector sent to instance". Read those last lines before you look at any frame list.

Release-only crashes are the nastiest category. Hermes optimizations, stripped symbols, and R8/ProGuard obfuscation all change behavior. A JS call that works fine in a debug build can fail in react native release mode crash scenarios because the debug build uses JSC with different timing and error semantics. Expo projects have the same issue: expo start --dev-client surfaces logs in development, but production builds go through a different native path, and expo crash debugging means reading a symbolicated report from a service, not the terminal. On Flutter, check for bugs in platform channels or engine-level segfaults in Skia/Impeller — those surface as native crashes with no Dart frames, and the flutter native crash debugging story relies on the same symbolication discipline as React Native.

A repeatable debugging workflow when you can't reproduce locally

Native crashes are state-dependent. You can't reproduce them by tapping through the app because they depend on memory pressure, timing, and device-specific behavior. So you need a workflow that captures the state you can't see.

First, capture the device model, OS version, and exact app version from the crash report. Then grab the last native log lines. On Android, adb logcat -d | tail -n 200 will show you the last thing the process printed before it died; on iOS, Xcode's console or a sysdiagnose log serves the same purpose. Those lines often name the failing module even when the symbolicated stack doesn't.

Second, add JS breadcrumbs to your crash reporter. If you record navigation events, button taps, and data mutations, you can reconstruct the JS state that led to the native fault. The setup is a few lines in your app's entry point:

import * as Sentry from '@sentry/react-native';
 
Sentry.init({
  dsn: 'https://example@sentry.io/project',
  tracesSampleRate: 1.0,
});
 
export function trackBreadcrumb(message: string, data?: Record<string, unknown>) {
  Sentry.addBreadcrumb({ message, data, level: 'info' });
}

Watch the gotchas: a missing dSYM upload means the report is hex addresses forever; bitcode can break symbolication unless you download the dSYM from App Store Connect after upload; and some crashes only happen in release builds under the debugger, so test with the same build configuration your users have.

When to fix it in JS and when to escalate

A surprising number of native crashes are fixable from JS. Resize images before rendering, avoid synchronous JSI calls on the UI thread, guard against undefined in event handlers, and clean up listeners on unmount. If the crash is inside a third-party native module, pin the version, check the project's issue tracker for a matching stack trace, and add a JS-side guard — feature-flag the call that triggers the module so you can disable it remotely if the crash spikes.

But you can't fix a genuine bug in a native dependency from JS. If the symbolicated stack lands inside a library's Objective-C or Kotlin code, and you've confirmed it's not a misuse on your side, prepare a minimal repro with the native stack and file an issue upstream. Include the device, OS version, library version, and the exact sequence of JS calls that precede the crash. A well-formed native stack trace is worth more than a thousand words of "it crashes sometimes".

Key takeaways

  • Native crashes in React Native and Flutter come from the boundary between the JS/Dart runtime and the native engine; JS error handling cannot catch them.
  • Read the exception type and the crashed thread first; everything else in the report is usually noise.
  • Symbolication requires dSYMs on iOS, R8/ProGuard mapping files on Android, and JS source maps on both — upload all three at build time.
  • Use a managed crash reporter that auto-symbolicates and groups by native frame; don't build your own pipeline.
  • Capture device state and JS breadcrumbs, fix what you can from JS, and escalate genuine native dependency bugs with a minimal repro.

Frequently asked questions

Why does my React Native app show a native crash instead of a JavaScript error?
React Native runs the JS engine in-process and renders through native components. When native code — the engine, a native module, or a platform view — hits a memory fault or throws an uncaught exception, the entire process dies. JavaScript errors are caught and shown as red boxes, but a native crash has no JS stack to display.
What does EXC_BAD_ACCESS mean and how is it different from SIGABRT?
EXC_BAD_ACCESS is a memory-access fault: the app touched memory it doesn't own, usually a dangling pointer, over-released object, or buffer overflow in native code. SIGABRT means the process called abort(), typically from an uncaught Objective-C/Swift exception or a failed C++ assert. Both kill the app, but the surrounding frames tell you whether you're debugging memory corruption or an exception.
Can I fix a native crash without writing Swift, Kotlin, or Java?
Often yes. Many native crashes come from JS-visible patterns: loading oversized images, calling a native module on the wrong thread, or mutating state during navigation. You still need to read the native stack to identify the failing module, but the fix is frequently in JavaScript. If the crash is inside a third-party library, pin the version or file an issue with a minimal repro.
#react-native#crash-reporting#symbolication#ios#android
Share

Keep reading