Dev.to · 9 min read

The Crash JavaScript Couldn't Catch: Fixing Android Native Crashes in a React Native App

The Crash JavaScript Couldn't Catch: Fixing Android Native Crashes in a React Native App

Our React Native app has one job that can't fail: know where the truck is. Truxo Tracker is the driver app for truxo.ai. Drivers accept freight loads, and the app runs background location tracking on Android — through the night, through dead zones in rural Texas, through Android deciding it knows better and killing the process. If tracking silently dies, a dispatcher loses visibility on a $40,000 load. If the app crashes in the driver's pocket at 3 AM, tracking dies. The app is built with React Native and Expo. For most of what we do, that's been a great trade. But Android background location is exactly the place where the abstraction gets thin, and over the last ten months I've spent a lot of time on the other side of it — reading Java stack traces in Crashlytics, patching Kotlin, and learning things about JobScheduler I never wanted to know. Here's the whole journey at a glance, then the story — including the part where one of my fixes caused a worse bug. The Android NullPointerException that started it all Late last year, Firebase Crashlytics started filling up with variations of this native crash: Fatal Exception: java.lang.RuntimeException java.lang.NullPointerException at expo.modules.taskManager.TaskJobService.onStartJob at expo.modules.taskManager.TaskService.handleJob at LocationTaskConsumer.executeTaskWithLocationBundles at TaskService.executeTask Not a JavaScript error. Not a red screen. A native Java NullPointerException inside expo-task-manager — the code path that wakes a React Native app up to deliver background location updates on Android. The first round of fixes, back in January, was the honest-looking stuff — the things that were actually our fault: We were using expo-background-task for something it was never meant to do. Removed it. TaskManager.defineTask has to run at app startup, before anything else, every time. Ours could race. Fixed the registration order. We were trying to start an Android foreground service while the app was backgrounded, which newer Android versions punish severely. Added defensive guards around the whole background task execution path in JavaScript. Crash volume dropped. But one family of NPEs kept coming back, and after staring at enough Crashlytics stack traces the chain became clear. There were two distinct root causes, and both of them lived below the JavaScript bridge: Here's the uncomfortable part: no amount of JavaScript try-catch can fix a native Android crash. The exception is thrown in Java/Kotlin before a single byte reaches the JS runtime. Your beautiful React error boundary never gets a vote. Patching native code in node_modules with an Expo config plugin The bugs lived in expo-task-manager's native source, inside node_modules. We filed upstream, but drivers were crashing now, and forking the module meant maintaining a fork forever. Expo has an escape hatch for this: config plugins with withDangerousMod. It's a hook that runs during expo prebuild, after node_modules exists but before the Android project is compiled. So I wrote an Expo config plugin that patches the vendored native source at build time: const withLocationTaskSafetyPatch: ConfigPlugin = (config) => { return withDangerousMod(config, [ "android", async (config) => { patchTaskJobService(config.modRequest.projectRoot) // try-catch at the top of the job chain patchLocationTaskConsumer(config.modRequest.projectRoot) // null-check the coords bundle patchTaskService(config.modRequest.projectRoot) // guard the null app loader return config }, ]) } Each patch is idempotent — it stamps a marker comment (TRUXO_SAFETY_PATCH_V3) into the file and skips if it's already there — so it survives yarn install, CI, and EAS builds. TaskJobService.onStartJob got wrapped in a try-catch that calls jobFinished() and bails instead of letting the exception bubble up to JobServiceEngine$JobHandler, which wraps anything it catches in a RuntimeException and kills your process. Yes, this is patching other people's code in node_modules. It's also versioned, documented, marker-guarded, and it shipped the same week. Sometimes the pragmatic thing and the pretty thing are different things. That config plugin started at 555 lines in February. It did not stay that size. The uncatchable SecurityException: when R8 strips your try-catch March brought a new Android crash. When a driver revoked location permission while tracking was active — which happens more than you'd think, usually because Android's own settings prompt suggested it — Google Play Services threw a SecurityException from an async callback posted straight onto the main Handler. I wrapped the call site in try-catch. It kept crashing. The reason took a while to accept: R8 was stripping the try-catch from the Kotlin lambda bytecode. Even with optimization flags dialed down, the exception surfaced in a synthetic lambda class where our handler simply didn't exist anymore, dispatched asynchronously so there was no call stack of ours anywhere near it. When there is genuinely no call site you can defend, there's one hook left: a global Thread.UncaughtExceptionHandler installed in MainApplication.onCreate, checking the stack for this exact signature — a SecurityException originating in LocationTaskConsumer / LocationModule — and swallowing that one specific crash while passing everything else through to Crashlytics. The config plugin grew a patch that injects this handler into MainApplication.kt. Around the same time we also wrote our own small pieces of native Android code — a BootReceiver to resume location tracking after device restarts and a LocationTrackingService with sane lifecycle handling — because at some point writing your own 200 lines of Kotlin is easier than defending someone else's. Crash rate kept falling. I felt pretty good. That lasted about two months. How my crash fix caused an Android ANR Somewhere along the way I had gotten greedy with the global exception handler. Instead of matching only the GMS SecurityException, I broadened it to swallow any exception whose stack touched the task manager classes. More crashes caught, right? In May, Crashlytics started showing ANRs (Application Not Responding) at process shutdown. The trace was bizarre: DestroyJavaVM hanging forever. It took real digging to connect it back to my own handler: When the broad handler swallowed a JobScheduler exception, it never called jobFinished(). The process kept running with a half-shut-down job and orphaned non-daemon threads — JobScheduler internals, wake locks — still alive. Later, when Android decided to kill the process, DestroyJavaVM sat waiting on those threads. Forever. That wait is an ANR, and it counts against you in Google Play vitals just like a crash. The fix — patch version V8, if you're counting — went the other direction: The global handler went back to being narrow — it only matches the R8-stripped GMS SecurityException, nothing else. All the other native paths are covered by source-level try-catch patches with @Keep annotations so R8 leaves them alone. And when the narrow handler does fire, it no longer just swallows and hopes. It logs a non-fatal to Crashlytics for visibility, then calls Process.killProcess on itself. A SIGKILL skips DestroyJavaVM entirely — no waiting on orphaned threads, no ANR — and JobScheduler restarts the app cleanly when the next location job fires. Deliberately killing your own process as the fix is a strange thing to type into a commit message. But a clean, instant death that Android recovers from beats a zombie process that shows up in Play vitals as an ANR. The lesson I'd underline twice: a catch-all exception handler is not a safety net, it's a liability with a delay on it. Every exception you swallow is a contract you're breaking with whoever threw it. JobScheduler expected jobFinished(). I didn't deliver. It collected two months later. What ten months of crash fixing bought us The native layer is only part of it — the same period included hardening the JavaScript side (null-page guards in our infinite queries, local error boundaries so a bad screen shows a Retry button instead of a white void, an offline outbox for chat), adding PostHog session replay and error tracking so we see problems before drivers report them, and starting a flag-gated migration to a dedicated native background-geolocation engine that runs in parallel with the old stack so we can compare them on real devices before switching. But the shape of the journey is what sticks with me: Fix your own bugs first. Most of our "native crashes" started as our misuse of the APIs — wrong task registration order, wrong service start timing. Crashlytics stack traces are the actual spec. Every durable fix came from reading the full native chain, not from the top frame. JavaScript error handling ends at the bridge. If the crash is in Java before your code runs, no JS construct will save you. You need a native answer. Patch at build time, with markers and versions. Our Expo config plugin is on V8, with explicit upgrade paths from every earlier version. It's infrastructure now, not a hack. Verify the fix didn't just move the problem. The broad exception handler traded visible crashes for delayed ANRs. Watch your Play vitals after shipping, not just your crash count. Drivers don't know any of this happened. The app just stopped dying in their pockets, and loads stopped going dark. That's the whole point — the best infrastructure work is the kind nobody notices. FAQ: Android native crashes in React Native Why doesn't try-catch fix native crashes in React Native? Because the exception is thrown in Java or Kotlin before execution ever reaches the JavaScript runtime. JS-level try-catch and React error boundaries only cover code running on the JS thread; a crash in a native module, a JobService, or a GMS callback kills the process directly. How do you patch a native bug in an Expo module without forking it? Write an Expo config plugin using withDangerousMod. It runs during expo prebuild — after node_modules is installed, before the native build — so you can modify the module's Java/Kotlin source in place. Make the patch idempotent with a marker comment so repeated builds don't double-apply it. What causes ANRs at process shutdown (DestroyJavaVM hang)? Orphaned non-daemon threads. If something (like a swallowed JobScheduler exception that never reached jobFinished()) leaves native threads alive, the JVM waits on them during teardown and Android reports an ANR. Can R8 really remove my try-catch blocks? In Kotlin lambda and synthetic-class bytecode, yes — R8 optimization can restructure code so your handler no longer wraps the throwing frame. @Keep annotations and source-level patches in the module itself are more reliable than call-site try-catch for these paths. I work on the mobile team at Truxo, where we build real-time freight load tracking. If you've fought similar battles with React Native background location on Android, I'd genuinely like to compare notes.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Startup & VC News