AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Mobile App OOM Crash Debugging: Fix Memory Kills

NFNourin Mahfuj Finick··8 min read

Your mobile app runs fine in development, passes QA, and launches successfully on the App Store and Google Play. Then users start reporting crashes — but there's no stack trace, no exception logged, and your crash reporter shows nothing unusual. What you're likely dealing with is a mobile app OOM crash — an Out-of-Memory termination where the operating system itself kills your app, not a bug in your code. These silent kills account for an estimated 70% of mobile app terminations in the background and are one of the hardest crash categories to debug — we've covered related background crash patterns in our mobile app background crash debugging guide. In this guide, you'll learn exactly how iOS Jetsam and Android's Low Memory Killer work, how to detect OOM crashes in production, and the strategies top engineering teams use to keep their memory footprint low enough to survive memory pressure events.

What Is an OOM Crash?

An OOM (Out of Memory) crash happens when the operating system terminates your application because the device is running critically low on available RAM. This is fundamentally different from a memory leak — a memory leak is your fault (retain cycles, uncached references, growing data structures). An OOM crash is the OS making a survival decision: your app is the most expendable process, so it gets killed to free memory for whatever the user is doing right now.

Apple calls this mechanism Jetsam. Android calls it the Low Memory Killer (LMK). On both platforms, the logic is the same: monitor system-wide memory pressure, rank processes by priority, and kill the lowest-priority heavy consumers when available RAM drops below a threshold.

Understanding memory management on iOS is critical because Apple does not publish Jetsam's exact thresholds, but community testing suggests iOS starts aggressively terminating apps when free memory drops below ~5% of total device RAM on most devices.

iOS Jetsam: How Apple's Memory Reaper Works

When iOS memory pressure rises, the kernel sends didReceiveMemoryWarning to every running app. This is your app's chance to voluntarily shed memory — clear image caches, release non-visible view controllers, flush networking buffers. If you handle this callback correctly, you might survive. If you ignore it, or if the pressure is severe enough that even well-behaved reductions aren't enough, Jetsam kills your app and generates a Jetsam report.

You can find Jetsam reports in the Xcode Organizer under Crashes, though they look different from exception-based crash logs. A typical Jetsam report contains the "per-process-limit" field and "reason": "highwater" or "reason": "vnode-limit" — the latter indicating you've exhausted the file descriptor table rather than physical RAM.

Here's what a minimal memory warning handler looks like in Swift:

override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
    imageCache.removeAllObjects()
    dataStore.flushUnusedBuffers()
    URLCache.shared.removeAllCachedResponses()
}

In UIKit applications, UIApplicationDidReceiveMemoryWarningNotification fires application-wide, so you should also subscribe in custom managers and service classes. Apple recommends reducing your app's memory use aggressively in response — releasing any data that can be recreated later.

Jetsam reports and diagnosing memory issues were covered in detail at WWDC 2020, and the diagnostic techniques remain relevant today for iOS 20 development.

Android LMK: Understanding onTrimMemory and oom_score_adj

Android's Low Memory Killer assigns every process an oom_score_adj value — a numeric score where higher numbers mean "kill this first." Your foreground activity has an oom_score_adj of 0, while a background service might sit at 700, and a cached process at 900+. When memory pressure hits, LMK starts killing from the highest scores downward.

Your primary defense is the onTrimMemory(int level) callback on your Application class. The critical levels are:

  • TRIM_MEMORY_RUNNING_CRITICAL (level 15): The device is extremely low on memory. Your process is not yet being considered killable, but you should release all non-critical resources immediately.
  • TRIM_MEMORY_RUNNING_LOW (level 10): The device is running lower on memory. Trim aggressively but your app is still safe.
  • TRIM_MEMORY_RUNNING_MODERATE (level 5): The system is beginning to feel memory pressure.
  • TRIM_MEMORY_BACKGROUND (level 40): Your process is on the LRU list and may be killed soon if memory doesn't improve.

Android's process management documentation provides the complete level reference. Here's a Kotlin example for your Application class:

override fun onTrimMemory(level: Int) {
    super.onTrimMemory(level)
    when (level) {
        ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
            imageLoader.clearMemoryCache()
            Glide.get(this).clearMemory()
            recyclerViewPool.clear()
        }
        ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
            imageLoader.trimMemory(level)
        }
    }
}

You can also inspect your current memory situation programmatically using ActivityManager.getMemoryInfo() and Debug.getNativeHeapSize(). The Android Memory Profiler in Android Studio gives you a real-time view of allocations, GC events, and heap dumps — invaluable for establishing a baseline footprint before testing under pressure.

Cross-Platform: Flutter and React Native OOM Defenses

OOM crashes don't discriminate by framework. If your Flutter or React Native app consumes too much memory, Jetsam and LMK will kill it just the same. The difference is that cross-platform frameworks abstract away the native memory warning callbacks, making it easy to miss them.

In Flutter, the WidgetsBindingObserver mixin gives you access to didHaveMemoryPressure:

class MyAppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  void didHaveMemoryPressure() {
    imageCache.clear();
    imageCache.clearLiveImages();
    PaintingBinding.instance.imageCache.clear();
  }
}

The Flutter engine's image cache is a major memory consumer; calling imageCache.clear() and PaintingBinding.instance.imageCache.clear() can free tens of megabytes on image-heavy screens. Additionally, set ImageCache.maximumSize to a reasonable value — the default of 1000+ cached images is rarely necessary on memory-constrained devices. While we've previously covered Flutter memory leak detection in depth, OOM crashes from system pressure require a different set of defenses.

In React Native, iOS memory warnings are accessible via the MemoryWarning event on DeviceEventEmitter:

import { DeviceEventEmitter } from 'react-native';
 
DeviceEventEmitter.addListener('MemoryWarning', () => {
  // Clear cached images, release large datasets
});

For Android, React Native relies on the host Activity's onTrimMemory, but you should also monitor the Hermes engine's heap via global.HermesInternal.getInstrumentedStats() to detect runaway allocations before they trigger an LMK kill. React Native memory profiling tools have matured significantly, and tracing JS heap sizes in production is now practical with a few lines of instrumentation.

Proactive Strategies: Reduce Your Footprint Before the OS Steps In

Handling memory warnings is reactive. The proactive approach is to not be the biggest consumer on the device in the first place. Here are the techniques that make the biggest difference:

1. Image Downsampling and Lazy Decoding

Loading a 12-megapixel photo into a 300×300 ImageView wastes ~50 MB of RAM for no visual benefit. On iOS, use CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize. On Android, use BitmapFactory.Options.inSampleSize combined with inJustDecodeBounds for dimension queries before allocation. For cross-platform, Coil (Android) and Nuke (iOS) handle this automatically; Coil's memory-efficient image loading saves significant RAM.

2. View Controller and Activity Lifecycle Discipline

Unused view controllers retained in navigation stacks and un-destroyed Activities holding references to destroyed views are classic footprint bloaters. On iOS, ensure you're not holding strong references to view controllers you've dismissed. On Android, explicitly null out view references in onDestroyView() and use WeakReference for any long-lived observer callbacks.

3. Background Task Budgeting

Both iOS and Android cap background execution time. On iOS, you get roughly 30 seconds in beginBackgroundTask blocks. On Android, WorkManager enforces constraints and defers work. Exceeding these limits doesn't just fail your task — it marks your process as a resource abuser, increasing its oom_score_adj and making it a prime LMK target.

4. Production Memory Monitoring

You can't fix what you can't measure. Instrument your app to report peak memory usage, memory warning frequency, and OOM crash rate. Bugspulse's privacy-first crash and memory monitoring captures Jetsam events and LMK kills without collecting personally identifiable information, letting you track OOM rates alongside traditional crash-free metrics. For a deeper look at production monitoring infrastructure, check our mobile app observability guide. Teams using production memory monitoring typically reduce OOM crashes by 40-60% within two release cycles.

Testing and Reproducing OOM Crashes Locally

The hardest part of OOM debugging is reproduction. These crashes happen on specific devices with specific RAM profiles under specific usage patterns. Here's how to trigger them predictably:

iOS Simulator: The easiest path. In the iOS Simulator, go to Hardware → Simulate Memory Warning. This fires didReceiveMemoryWarning on your app. To actually trigger Jetsam, allocate hundreds of megabytes in a tight loop while running on a simulator configured with a low-memory device profile (e.g., iPhone SE with 2 GB RAM).

Android Emulator: Configure an AVD with 512 MB or 1 GB of RAM in the advanced settings. Then use adb shell am kill on background processes to reduce available memory, or use the dumpsys meminfo command to watch your app's PSS (Proportional Set Size) grow. Setting the device to a low-RAM profile triggers onTrimMemory more aggressively.

Stress Testing: Create an automated UI test that rapidly navigates between high-memory screens (image galleries, map views, video players) while simultaneously performing network requests. Run this on a low-RAM device or emulator and monitor for didReceiveMemoryWarning frequency and eventual termination. Integrate these tests into your CI pipeline to catch memory regressions before they ship.

Using a monitoring platform like Bugspulse's mobile observability suite gives you per-release OOM rates, device-specific memory profiles, and early warnings when a new version starts triggering more memory kills than the previous one. The best OOM crash is the one caught in staging, not in production.


OOM crashes may not leave stack traces, but they leave patterns. Once you understand the signals — memory warnings, Jetsam reports, oom_score_adj values, and production memory metrics — you can systematically eliminate the biggest sources of memory pressure in your app. Start by implementing the memory warning handlers shown above, add production memory monitoring to track your baseline, and set up CI tests that catch regressions early. The result is an app that survives memory pressure instead of being the first one killed.

Ready to track OOM crashes across your iOS and Android apps? Sign up for Bugspulse and start monitoring memory events, Jetsam kills, and LMK terminations alongside your regular crash data — all without collecting user PII.