How to Debug Crashes in Mobile Apps
How to Debug Crashes in Mobile Apps
How to Debug Crashes in Mobile Apps
Debugging crashes is a core skill for any mobile developer or QA engineer. When an application terminates unexpectedly, users lose trust, ratings drop, and support costs rise. The goal of this guide is to give you a repeatable, hands‑on process for identifying the root cause, reproducing the failure reliably, applying the right fix, and preventing similar issues in the future. We’ll cover the most common crash categories, the tools that expose the needed signals, a step‑by‑step workflow, concrete examples, and a practical checklist you can bookmark.
---
Understanding Common Crash Root Causes
Before you can fix a crash you need to know what typically makes a mobile app abort. Below are the primary categories you’ll encounter on Android and iOS, each with characteristic symptoms and typical origins.
Memory Issues (OOM, Leaks)
Out‑of‑memory errors happen when the Dalvik/ART heap (Android) or the Objective‑C/Swift heap (iOS) cannot allocate more memory. Symptoms include a sudden termination with a signal like SIGABRT and a log line containing “Failed to allocate … bytes”. Common causes:
- Holding references to bitmaps, drawables, or large data structures after they are no longer needed.
- Caches that grow without bounds (e.g., LruCache with a too‑high limit).
- Recursive algorithms that allocate temporary objects on each call.
- Native libraries that allocate memory via
mallocand never free it.
On Android you’ll often see java.lang.OutOfMemoryError: Failed to allocate … bytes in logcat; on iOS the crash report will show EXC_RESOURCE RESOURCE_TYPE_MEMORY.
Null Pointer Exceptions
A null‑pointer dereference is the most frequent crash on managed runtimes. The stack trace will point to a line where an object reference is used without being checked. Typical scenarios:
- UI components accessed before
onCreatefinishes (Android) or beforeviewDidLoad(iOS)). - Callbacks from asynchronous APIs that arrive after the owning object has been deallocated.
- Third‑party SDKs returning null under edge‑case conditions (e.g., no network, missing permissions).
The crash signature often looks like java.lang.NullPointerException: Attempt to invoke virtual method 'void …' on a null object reference.
Threading and Race Conditions
Mobile apps run on a main (UI) thread plus several worker threads. Improper synchronization can lead to crashes such as:
- Accessing UI elements from a background thread (Android’s
CalledFromWrongThreadException). - Data races on shared mutable state (e.g., a
HashMapupdated by multiple threads without locking). - Deadlocks when two threads each wait for a lock held by the other.
These crashes may be intermittent and only appear under specific timing conditions, making them hard to reproduce.
Native Crash (NDK / Cocoa Touch)
When you ship native code—whether via the Android NDK, a Cocoa Touch static library, or a third‑party SDK—crashes surface as signals like SIGSEGV, SIGILL, or SIGBUS. The tombstone (Android) or crash log (iOS) will contain a native back‑trace with addresses that need symbolication. Typical causes:
- Buffer overruns or underruns in C/C++ code.
- Misuse of JNI calls (e.g., passing a local reference after the native method returns).
- Incompatible ABI between your app and a pre‑built library.
Resource Loading Failures
Attempting to load a resource that is missing, corrupted, or inaccessible can abort the app. Examples:
- Trying to inflate a layout that references a non‑existent drawable ID.
- Loading a custom font from the assets folder that fails because the file is zero‑byte.
- Attempting to open a database file on external storage when the permission is denied.
These failures often manifest as android.content.res.Resources$NotFoundException or NSInternalInconsistencyException on iOS.
---
Reproducing Crashes Reliably
A crash that cannot be reproduced is a debugging dead‑end. The first practical step is to capture enough context to trigger the same failure in a controlled environment.
Capturing Steps from Logs
When a crash occurs, the device’s logcat (Android) or console (iOS) often contains a breadcrumb trail:
# Android: pull the last 500 lines around the timestamp of the crash
adb logcat -d -v threadtime | grep -B5 -A5 "FATAL EXCEPTION"
- Look for user actions preceding the crash (button clicks, scroll events).
- Note any custom logs you inserted (e.g.,
Log.d("Flow", "Started login")). - On iOS, use
Console.apporlog show --predicate 'process == "YourApp"' --last 2h.
If you have integrated a crash reporting service, it will usually attach a “steps to reproduce” field based on breadcrumbs you logged manually or via an SDK.
Using Bug Reporting Tools
Tools like Firebase Crashlytics, Sentry, or Instabug automatically capture:
- Device model, OS version, RAM, storage.
- Application state (foreground/background, battery level).
- Custom keys and logs you set with their APIs.
Enable the SDK early in Application.onCreate() (Android) or application:didFinishLaunchingWithOptions: (iOS). Then you can query the dashboard for a specific crash signature and see the attached logs.
Automated Reproduction with SUSA
SUSA’s autonomous explorer can exercise an app without scripts, generating real user flows that often surface hidden crashes. To use it for reproduction:
- Upload the APK or point SUSA at a staging URL.
- Choose a persona that matches the suspected user type (e.g., “impatient” for rapid taps, “elderly” for slow gestures).
- Run a session and let the platform explore until it either finds a crash or exhausts its depth limit.
- Export the generated Appium (Android) or Playwright (Web) script; you can then replay it locally to verify the failure.
Because SUSA remembers explored screens and dead ends, each subsequent run becomes smarter, gradually increasing the chance of hitting the exact condition that caused the original crash.
Creating Minimal Test Cases
Once you have a rough reproduction, strip away unrelated code to isolate the fault:
- Create a new Android Studio module or Xcode test target that only includes the suspect class or component.
- Replace external dependencies with mocks or fakes (e.g., use Mockito for Android, OCMock for iOS).
- Feed the same input data that triggered the crash (e.g., a specific JSON payload, a bitmap of certain dimensions).
A minimal reproducible example (MRE) makes it far easier to add breakpoints, watch variables, or run under a sanitizer.
---
Essential Debugging Tools and Signals
Effective debugging relies on gathering the right signals from the device, the runtime, and your own instrumentation. Below are the core tools you should have in your toolbox.
Logcat and Console Logs
Logcat is the primary window into Android runtime events. Useful command‑line patterns:
# Show only your app’s logs, colored by priority
adb logcat -s MyApp:V *:S
# Follow live output and filter for a specific tag
adb logcat | grep -i "NullPointer"
On iOS, the unified logging system works similarly:
log show --style syslog --predicate 'processImagePath contains "MyApp"' --last 30m
Add structured logs with timestamps and correlation IDs to make post‑mortem correlation trivial.
Crash Reporting Services
Services such as Firebase Crashlytics give you:
- Crash frequency over time.
- Stack traces with line numbers (if you uploaded symbols).
- Custom keys (e.g.,
user_id,experiment_group). - Breadcrumbs (logs you manually add).
Integrate early and enable symbol upload in your CI pipeline:
# Android: upload ProGuard mapping
./gradlew uploadCrashlyticsMappingFileRelease
# iOS: upload dSYM
./upload-symbols -gsp /path/to/GoogleService-Info.plist -p ios /path/to/MyApp.dSYM
Profilers (Android Studio Profiler, Instruments)
When a crash is related to resource exhaustion, profilers reveal the trend leading up to the failure:
- Memory Profiler – tracks heap allocation, shows which classes retain memory.
- CPU Profiler – spots threads that are spinning or blocking.
- Network Profiler – identifies stalled or failing requests that might precede a crash.
Instruments (iOS) offers analogous templates: Allocations, Leaks, Core Animation, and System Trace.
Tombstones and Core Dumps
On Android, a native crash writes a tombstone file to /data/tombstones/. Retrieve it with:
adb root # if you have a debuggable build
adb pull /data/tombstones/tombstone_00 ./tombstone.txt
The tombstone contains registers, stack memory, and a native back‑trace. Symbolicate it with ndk-stack:
ndk-stack -sym ./app/build/intermediates/cmake/debug/obj/ -dump ./tombstone.txt
On iOS, the crash report includes a binary image section; symbolicate with symbolicatecrash:
symbolicatecrash ./MyApp_2024-09-24-153212.crash ./MyApp.app.dSYM > ./symbolicated.txt
Network Traces
Sometimes a crash is triggered by a malformed server response. Capture HTTP/HTTPS traffic with:
- Android:
adb shell am start -n com.android.settings/.Settings$ProxySelectorActivityto set a HTTP proxy, then use Charles or mitmproxy. - iOS: Set up a Wi‑Fi proxy or use
rvictlto tether the device to a Mac and run Wireshark.
Look for non‑200 status codes, unexpected JSON structure, or binary data where text is expected.
---
Step‑by‑Step Diagnosis Workflow
Having gathered signals, follow this repeatable workflow to move from symptom to root cause.
1. Triage: Isolate the Crash Signature
- Open your crash dashboard and locate the cluster with the highest impact (most occurrences, newest).
- Record the signature: exception type, top‑most frames, and any custom keys.
- If multiple signatures appear, treat each as a separate investigation.
2. Gather Context: Device, OS, Build
- Note the exact device model, Android API level or iOS version, and build variant (debug/release, flavor).
- Check whether the crash is limited to a specific hardware configuration (e.g., only on devices with <2 GB RAM).
- Correlate with feature flags or A/B test groups if you use them.
3. Analyze Stack Trace
- For managed exceptions, read the trace from top to bottom; the first line that belongs to your code is the likely origin.
- For native traces, locate the first address that maps to your library after symbolication.
- Annotate each frame with the trace with hypotheses (e.g., “frame 3: bitmap decode → possible OOM”).
4. Reproduce in Controlled Environment
- Use the steps captured from logs or the SUSA‑generated script.
- Run on a device or emulator that matches the problematic profile.
- If the crash is intermittent, increase the iteration count (e.g., run the script 50 times) to raise the probability of hitting the race condition.
5. Instrument Code for Deeper Insight
- Add strategic logs before and after the suspected call.
- Enable strict mode (Android) to catch accidental disk or network access on the main thread:
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build());
}
- On iOS, enable the Address Sanitizer (
-fsanitize=address) or Thread Sanitizer (-fsanitize=thread) in your build settings to catch memory overruns and data races at runtime.
6. Verify Fix and Regression
- Apply the hypothesized fix.
- Run the reproduction steps again; the crash should no longer occur.
- Run your full test suite (unit, UI, and any autonomous exploration scripts) to ensure you haven’t introduced regressions.
- Upload a new build with updated symbols to your crash reporting service and monitor for the signature’s disappearance.
---
Fixing Common Crash Categories
Now that you have a reproducible case, apply the appropriate remedy for each crash family.
Memory Leaks and OOM
- Identify leaking objects with the Android Studio Memory Profiler’s “Heap dump” or Instruments’ “Allocations → Mark Heap”. Look for growing instances of
Bitmap,Drawable,ArrayList, or custom caches. - Break reference cycles:
- In Java/Kotlin, avoid static references to
ContextorActivity. UseWeakReferenceor the application context when appropriate. - In Swift, watch for strong reference cycles in closures; use
[weak self]capture lists. - Limit cache size: Use
LruCachewith a size calculated from available memory (ActivityManager.getMemoryClass()). - Downsample bitmaps: Before loading a large image, compute
inSampleSizebased on the targetImageViewdimensions. - Native memory: Run
valgrindorAddressSanitizeron your NDK builds; free everymallocwith a matchingfree.
Null‑Pointer Defensive Coding
- Null‑check at the entry point:
fun loadUser(userId: String?) {
if (userId == null || userId.isEmpty()) {
Log.w("UserRepo", "Received null userId")
return
}
// …
}
String?, !! only when you are certain).
guard let data = try? Data(contentsOf: url) else {
logger.error("Failed to load data")
return
}
isAdded() before interacting with a Fragment’s view; in iOS, verify view.window != nil before updating UI.Thread Safety Practices
- Confine UI work to the main thread:
- Android:
runOnUiThreadorLifecycleOwner.lifecycleScope.launchWhenStarted { … }. - iOS:
DispatchQueue.main.async { … }. - Protect shared mutable state with appropriate synchronization primitives:
- Java:
synchronizedblocks,ReentrantLock, orCopyOnWriteArrayList. - Kotlin:
@Synchronizedannotation orMutexfrom coroutines. - Swift:
DispatchQueuewith aserialqueue orNSLock. - Prefer immutable data structures when passing data between threads.
- Use higher‑level abstractions: Android’s
WorkManageror Kotlin’sflow; iOS’sOperationQueueor Combine publishers.
Handling Native Libraries
- Enable strict mode for JNI: In your
AndroidManifest.xmlsetandroid:debuggable="true"and add:
<meta-data android:name="com.android.native.debuggable" android:value="true"/>
DeleteGlobalRef.abiFilters in Gradle to package only the needed armeabi-v7a, arm64-v8a, x86, x86_64 libraries.-fsanitize=address -fno-omit-frame-pointer and inspect the tombstone for sanitizer reports.Graceful Resource Degradation
- Provide fallbacks: If a custom font fails to load, fall back to the system font; log the failure but do not crash.
- Validate resource IDs at runtime:
val drawableId = resources.getIdentifier("my_icon", "drawable", packageName)
if (drawableId == 0) {
Log.w("Res", "Drawable my_icon not found")
return ContextCompat.getDrawable(this, R.drawable.ic_placeholder)
}
ContextCompat.checkSelfPermission and request at runtime if needed.---
Prevention Strategies and Best Practices
Preventing crashes is cheaper than fixing them after release. Integrate these habits into your development lifecycle.
Static Analysis and Lint
- Enable Android Lint with checks for
NullPointer,ResourceType, andInstantiatable. - Use SpotBugs or Detekt for additional bug patterns.
- On iOS, run Clang Static Analyzer (
scan-build) and SwiftLint to enforce safety rules.
Unit and UI Testing with Autonomous Exploration
- Write unit tests that exercise edge cases: null inputs, empty collections, large payloads.
- Create UI tests (Espresso/XCUITest) that simulate the user flows most likely to trigger a crash (rapid taps, rotation changes, network loss).
- Leverage SUSA in CI to run an exploratory session on every pull request; treat any newly discovered crash as a blocker.
Continuous Integration Crash Gates
- Configure your CI to fail the build if the crash reporting service detects a new signature in the beta channel.
- Upload symbols automatically so that any crash is immediately symbolicated.
- Use feature flags to gradually roll out risky changes and monitor crash rates per flag.
Monitoring and Alerting
- Set up alert thresholds (e.g., >0.1% crash‑free users drop) in Firebase Crashlytics or Sentry.
- Track ANR rates alongside crashes; they often share root causes (main‑thread blocking).
- Correlate crash spikes with deploys, library updates, or remote config changes.
Code Review Checklists
Add the following items to your pull‑request review template:
| Checklist Item | Why It Matters |
|---|---|
No raw Context or Activity stored in static fields | Prevents memory leaks |
All findViewById/inflater calls guarded against null | Avoids NPE |
| UI updates dispatched to main thread | Prevents CalledFromWrongThreadException |
| JNI local references released after use | Avoids native crashes |
| Resource access uses runtime identifier lookup with fallback | Stops Resources$NotFoundException |
New native libraries added with proper abiFilters and symbol upload | Guarantees correct symbolication |
| Any third‑party SDK wrapped in a facade with null‑checks | Isolates external instability |
| Unit test added for each new public method that handles input validation | Increases test coverage |
| Reviewer verifies that logging does not contain PII | Maintains privacy compliance |
---
Real‑World Examples and Lessons Learned
Concrete cases help cement the abstract advice above. Each example includes the symptom, the investigation steps, the root cause, and the fix.
Example 1: Image Loading OOM
Symptom: On low‑end Android devices (2 GB RAM), the app crashed with java.lang.OutOfMemoryError: Failed to allocate 12457856 bytes after scrolling through a gallery of high‑resolution photos.
Investigation:
- Logcat showed a spike in
BitmapFactory.decodeStreamcalls right before the OOM. - Memory Profiler revealed a steady increase in
Bitmapobjects that were never released. - The gallery used a
RecyclerViewwith anImageViewthat calledBitmapFactory.decodeFileon the main thread for each item.
Root Cause: The app decoded full‑resolution images (often 4 MB+ each) and kept them in memory without recycling or downsampling.
Fix:
- Implemented
BitmapFactory.OptionswithinJustDecodeBoundsto compute an appropriateinSampleSizebased on theImageViewdimensions. - Switched to using Glide, which handles caching and downsampling automatically.
- Added a unit test that asserts the decoded bitmap’s byte size never exceeds a threshold (e.g., 500 KB).
Lesson: Always downsample images to the size they will be displayed; never rely on the device’s heap to absorb large bitmaps.
Example 2: Race Condition in DB Access
Symptom: Intermittent android.database.sqlite.SQLiteMisuseException: library routine called out of sequence on a Nexus 5X running Android 9, occurring roughly once per 200 launches.
Investigation:
- Crashlytics stack trace pointed to
SQLiteDatabase.beginTransaction()followed by anotherbeginTransactionwithout an interveningendTransaction. - Added thread‑id logs around DB open/close; revealed that two background workers were simultaneously opening the same
SQLiteOpenHelperinstance.
Root Cause: The helper was a singleton, but its getWritableDatabase() method was not synchronized, allowing two threads to obtain separate database objects that shared the same underlying SQLite connection.
Fix:
- Made
getWritableDatabase()synchronized or switched to usingRoom, which serializes database access. - Added a test using
CountDownLatchto trigger concurrent accesses and assert no exception is thrown.
Lesson: Even “thread‑safe” singletons can expose unsafe mutable state; prefer proven abstractions like Room or use explicit locking.
Example 3: Third‑Party SDK Native Crash
Symptom: Crash reports showed SIGSEGV in libfoo.so at address 0x00007f9a3c2f10, only on devices with ARM64 processors running Android 10.
Investigation:
- Downloaded the tombstone via
adb pull. ndk-stackrevealed the fault infoo_process_imagewhere a pointer passed to a native function was0x0.- The SDK documentation mentioned that the input buffer must be non‑null and aligned to 16 bytes.
Root Cause: The app passed a null ByteBuffer when the image download failed, and the SDK did not validate the pointer before dereferencing it.
Fix:
- Added a guard in the Kotlin wrapper:
if (imageBuffer == null || !imageBuffer.hasRemaining()) {
Log.w("FooSDK", "Skipping null buffer")
return
}
fooNative.processImage(imageBuffer)
Lesson: Treat all third‑party native interfaces as unsafe boundaries; validate inputs before crossing into native code.
Example 4: Accessibility Service Interference
Symptom: TalkBack users reported random crashes when navigating a settings screen; stack trace showed android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid for input.
Investigation:
- Logs indicated that an accessibility service was attempting to overlay a custom view on top of the activity while the activity was in the process of finishing.
- The crash only appeared when the user double‑tapped to activate a button quickly.
Root Cause: The app launched a dialog using getApplicationContext() as the window token, which is invalid when the activity is no longer visible.
Fix:
- Changed dialog creation to require an
Activitycontext:
fun showInfoDialog(activity: Activity) {
AlertDialog.Builder(activity)
.setMessage(R.string.info)
.setPositiveButton(android.R.string.ok, null)
.show()
}
Lesson: Always use an Activity‑scoped context for UI that needs a window token; never rely on the application context for dialogs, toasts, or pop‑ups.
---
Checklist for Crash Debugging
Use this table as a quick reference when you encounter a new crash.
| Phase | Action | Tool / Command | |
|---|---|---|---|
| Detection | Identify new crash signature | Crashlytics / Sentry dashboard | |
| Context | Record device, OS, build, user | adb shell getprop ro.build.version.sdk | |
| Reproduction | Capture steps from logs or breadcrumbs | `adb logcat -d -v threadtime | grep -B5 -A5 "FATAL EXCEPTION"` |
| Re‑create | Run steps on matching device/emulator | Manual or SUSA‑generated script | |
| Instrumentation | Add logs, enable strict mode, sanitizers | StrictMode, -fsanitize=address | |
| Analysis | Examine managed stack trace or tombstone | adb pull /data/tombstones/tombstone_00, ndk-stack | |
| Fix | Apply defensive check, resource guard, thread fix | Code change | |
| Verification | Re‑run reproduction, run test suite | ./gradlew connectedAndroidTest, xcodebuild test | |
| Monitoring | Watch for signature disappearance | Crashlytics trend alert | |
| Prevention | Add unit/UI test, update lint rules, review CI gate | detekt, lint, CI config |
---
Closing Takeaways
Debugging crashes is a blend of systematic evidence gathering, targeted experimentation, and disciplined prevention. Start by capturing a clear signature and the surrounding context, then reproduce the failure in a controlled setting. Use the appropriate tool—logcat for breadcrumbs, profilers for resource trends, tombstones for native details—to zero in on the offending code. Apply well‑known fixes: null‑guards, thread‑safe constructs, proper memory management, and safe resource handling. Finally, embed the lessons into your workflow through static analysis, automated exploration (SUSA), CI gates, and vigilant monitoring. By treating each crash as a learning opportunity, you’ll improve both the stability of your current release and the resilience of future ones.
---
*Word count: approximately 4150.*
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free