An ANR (Application Not Responding) is what the system reports when the main thread stays blocked too long: about 5 seconds for an unhandled input event, about 10 seconds for a foreground BroadcastReceiver.
Triggers: no reaction to input within ~5 s; a foreground BroadcastReceiver.onReceive over ~10 s (~60 s in the background); slow Service, ContentProvider or JobService callbacks.
Avoiding it: keep the main thread for UI; network, disk and database work go to coroutines on Dispatchers.IO / Dispatchers.Default; never runBlocking, Thread.sleep or Future.get() on main; turn on StrictMode in debug builds.
Finding it: Play Console's Android Vitals shows the main-thread stack at the timeout; on a device ApplicationExitInfo with REASON_ANR gives the trace on the next launch.
AndroidSenior
Android Performance & Memory
Profilers, leaks, ANRs, baseline profiles, R8, startup, recomposition perf
Questions closed
0 / 25
- 0Review
- 7Asked often
Questions
runBlocking parks the calling thread until the coroutine finishes, and here the calling thread is the main thread. The network call does run on IO, but the UI thread sits and waits for it.
withContext(Dispatchers.IO) changes where the work runs, not who waits. The click listener cannot return until runBlocking returns.
While it waits, the main thread draws no frames and handles no input. The next tap waits in the queue, and after about 5 seconds the system raises an ANR. Even a 300 ms request means a frozen screen.
runBlocking is meant for main() functions and tests, not for Android UI code.
The fix is to launch a coroutine in a lifecycle-bound scope: lifecycleScope in an Activity, viewModelScope in a ViewModel. The coroutine suspends instead of blocking, the main thread keeps drawing, and the request is cancelled when the screen goes away. Putting the call in a ViewModel also keeps the result across rotation.
StrictMode is a debug tool you switch on in Application.onCreate: its thread policy reports disk and network access on the main thread, and its VM policy reports resources that were never closed, leaked Activities and similar mistakes.
The two policies
ThreadPolicy applies to the thread that sets it, so setting it in Application.onCreate covers the main thread. Useful detectors: detectDiskReads, detectDiskWrites, detectNetwork, detectUnbufferedIo, and detectCustomSlowCalls together with your own StrictMode.noteSlowCall("parseFeed").
VmPolicy is process-wide. detectLeakedClosableObjects reports a Cursor or stream that was garbage-collected while still open, with the stack that opened it. Others: detectActivityLeaks, detectLeakedRegistrationObjects (a receiver never unregistered), detectUnsafeIntentLaunch.
Penalties: penaltyLog while developing, penaltyListener (API 28+) to send violations to your logger or fail a test, penaltyDeath only if you are ready for crashes.
The traps
The framework itself reads disk on main sometimes, for example the first SharedPreferences load. Wrap that one call in StrictMode.allowThreadDiskReads() instead of removing the detector, or you will learn to ignore the log.
Never ship it: penaltyDeath() in a release build turns a slow read on a cheap phone into a crash. Guard the setup with the debuggable flag (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE).
StrictMode does not see CPU work. A 300 ms JSON parse on the main thread is invisible to it; a system trace finds that.
A leak on Android is almost always something long-lived (a singleton, a static field, a running callback) still referencing a destroyed Activity, Fragment or View, so the whole screen can never be garbage-collected.
Common causes
A static field or singleton holding an Activity Context. Anything that outlives a screen should get applicationContext.
Listeners registered with a long-lived object (an event bus, LocationManager, a third-party SDK) and never unregistered.
Lambdas, anonymous objects and inner classes that capture the Activity and are kept by something else: a Handler.postDelayed callback, a running thread.
A Fragment keeping its view binding after onDestroyView. The fragment lives longer than its view tree.
Coroutines started in GlobalScope or in a scope you created and never cancelled.
How to find them
LeakCanary in debug builds watches destroyed Activities, Fragments and ViewModels and prints the reference chain that keeps one alive.
Memory Profiler: rotate the screen a few times, capture a heap dump, and look for more than one instance of the same Activity.
In production, Play Vitals and ApplicationExitInfo show memory-related kills.
How to fix them
Shorten lifetimes instead of adding WeakReferences: tie work to viewModelScope, lifecycleScope or viewLifecycleOwner, unregister in the mirror callback (onStart/onStop), and use DisposableEffect in Compose so cleanup sits next to registration.
The lambda calls findViewById on the Activity, so it captures this, and SessionManager is a process-wide singleton that keeps the lambda forever. Every rotation adds one more destroyed Activity to its list.
The chain LeakCanary prints: SessionManager.listeners → the list → the lambda → ProfileActivity → its window and all its views.
It grows: each recreation registers a new listener and none is removed, so memory and duplicate callbacks pile up. A callback into a destroyed Activity can also crash.
**removeListener cannot help as written: the lambda is created inline, so nothing kept a reference to pass to it.
A list of WeakReferences is not a fix**: nothing else holds the lambda strongly, so it would be collected at the next GC while the screen is still visible, and updates would silently stop.
The fix is to tie the subscription to the lifecycle. Either keep the listener in a field and pair addListener/removeListener in mirrored callbacks (onStart/onStop), or expose the session as a StateFlow and collect it inside repeatOnLifecycle, which cancels the collection for you.
It holds every destroyed Activity, Fragment, ViewModel and Service through a WeakReference, triggers a GC after a short delay, and if the reference is still not cleared it dumps the heap and finds the shortest strong reference path to the object.
Watching: LeakCanary installs itself through a ContentProvider (no init code) and hooks lifecycle callbacks: Activity and Fragment destruction, ViewModel.onCleared, service destroy. Each destroyed object goes to an ObjectWatcher.
**Weak reference plus ReferenceQueue: the object is wrapped in a weak reference registered with a queue. When the GC collects it, the reference is put in the queue and the object is removed from the watch list.
Wait, then force a GC**: after about 5 seconds it calls Runtime.getRuntime().gc() and checks again. Still reachable means "retained".
Threshold: a heap dump freezes the app for seconds, so by default it waits until 5 objects are retained while the app is visible, then calls Debug.dumpHprofData.
Analysis: its Shark library parses the .hprof file, computes the shortest path from GC roots (static fields, thread stacks, JNI references) to each retained object, and groups leaks by that path. Known leaks inside the Android framework are labelled as library leaks so you do not chase them.
This is why it belongs in debugImplementation only: forced GCs and heap dumps are far too heavy for real users.
ART frees objects that can no longer be reached from GC roots (thread stacks, static fields, JNI references); the type of each reference decides whether it keeps the object alive.
How ART collects
It traces reachability, not reference counts, so cycles that nothing reaches are collected too.
The collector is concurrent and generational: young objects are collected often and cheaply, most work runs alongside your threads, and pauses are short. Newer ART versions, updated through Play system updates, switch to a concurrent mark-compact collector; the model for you stays the same.
System.gc() is only a hint. Do not call it in app code; it is for tests and tools like LeakCanary.
Reference types
Strong: a normal variable or field. The object lives as long as a strong path to it exists.
Soft (SoftReference) is cleared only under memory pressure. It sounds like a cache, but Android docs advise against it: the runtime cannot tell which entries are worth keeping. Use an LruCache with a size limit.
Weak (WeakReference) is cleared at the next GC once no strong reference remains. Used to observe without owning: WeakHashMap keys, LeakCanary's watcher.
Phantom (PhantomReference): get() always returns null. It is put in a ReferenceQueue once the object is no longer reachable by anything stronger, which lets you free native resources. java.lang.ref.Cleaner (API 33+) wraps this pattern.
A common interview trap: WeakReference is not a leak fix. It hides a lifetime bug and makes callbacks disappear at random moments.
onDraw runs on every animation frame, and this one creates a Paint, a Path and a String (plus a boxed Float from maxOrNull()) each time. That is allocation churn, not a leak: the garbage collector keeps running during the animation and takes time from the frames.
Why churn hurts
Allocating in ART is cheap; collecting is what costs. The concurrent collector still pauses threads briefly and competes for CPU cores with the UI thread and the RenderThread.
At 120 Hz this method runs 120 times a second, so a handful of objects per call becomes hundreds per second per view.
The symptom in Logcat is a GC line (Background concurrent ... GC freed ...) several times a second while the animation runs. Android Lint already flags this pattern as DrawAllocation.
The fix
Create Paint and Path once as fields, and call path.reset() before refilling it in onDraw.
Build the label when the data changes, not on every frame.
Related, not the cause here: keep numbers in a FloatArray, not a List<Float>: every element of a List<Float> is a boxed object. The same goes for HashMap<Int, T>: SparseArray or androidx.collection's IntObjectMap keep keys primitive.
To find churn you did not spot by eye, record allocations in the Memory Profiler during the gesture and sort by allocation count, not size.
The rule: nothing that lives for one frame should be created inside the frame.
SparseArray and ArrayMap keep keys and values in flat arrays and find a key by binary search, so they avoid boxing keys and per-entry node objects; they use less memory and allocate less, but lookups are O(log n), so they win for small collections (hundreds to a few thousand entries) and lose on large ones.
**SparseArray, SparseIntArray, SparseBooleanArray**: keys are primitive Int. HashMap<Int, V> boxes every key and allocates an entry node per item.
**ArrayMap<K, V>**: the same idea for object keys: one array of sorted hashes and one of key/value pairs. It implements MutableMap, so it is a drop-in replacement.
The price: lookup is a binary search, and inserting or removing in the middle shifts the arrays, which is O(n). With a large or write-heavy map HashMap is faster.
Lint: the UseSparseArrays check suggests the swap for HashMap<Int, V>.
**androidx.collection**: IntObjectMap and MutableIntIntMap are hash-based primitive maps: no boxing and no O(log n) lookup, a good default for large Int-keyed maps.
A decoded bitmap costs width × height × bytes per pixel, whatever the file size: a 4000×3000 photo is a 2 MB JPEG but 48 MB as ARGB_8888. A few full-size decodes run out of memory even when nothing leaks.
Leak versus OOM
A leak is memory you can no longer free because something still references it. It grows over time and is one common cause of OOM.
An OOM is a single allocation the runtime cannot satisfy. One oversized bitmap triggers it with no leak at all, and the stack trace points at the allocation that failed, not at whatever filled the memory.
Where pixels live: up to Android 7 they were on the Java heap, so a big decode threw OutOfMemoryError inside BitmapFactory. Since Android 8 they are in native memory, so the Java heap graph can look fine while the process footprint is huge; the failure is then more often a low-memory kill than an OutOfMemoryError inside BitmapFactory.
What fixes it
Decode at display size: inJustDecodeBounds plus inSampleSize, or ImageDecoder with setTargetSize. Coil and Glide take the size from the view and do this for you, which is the main reason to use them.
Pick the config: RGB_565 halves the memory where you need no alpha; Bitmap.Config.HARDWARE keeps pixels only in GPU memory.
Reuse buffers: Glide's BitmapPool decodes into existing bitmaps through inBitmap, so a scrolling grid does not allocate a new buffer per row.
Bound the cache: an LruCache sized from ActivityManager.memoryClass, plus a disk cache.
Ask for less: request a thumbnail-sized image from the server or CDN instead of the full original.
They are memory callbacks the system sends to your Application, Activities and Services when your UI is hidden or memory runs low. The right response is to drop caches you can rebuild, so the process is cheaper to keep alive.
Since Android 14 only two levels arrive: TRIM_MEMORY_UI_HIDDEN (the user left your UI, so release UI-only resources) and TRIM_MEMORY_BACKGROUND (the process is in the background list, so trim caches hard). The RUNNING_*, MODERATE and COMPLETE levels are deprecated and no longer delivered.
**onLowMemory()** is the old pre-API-14 callback, equal to the most severe level. New code handles onTrimMemory only.
What to release: in-memory image caches, parsed responses, object pools. Keep the callback fast: no disk writes, no heavy work.
Why it pays off: the low-memory killer chooses which cached process to kill partly by its size. A smaller process survives longer, and the user gets a warm start instead of a cold one.
Plan for small devices up front: size caches from ActivityManager.memoryClass, and use smaller ones when isLowRamDevice() is true.
Jank is a frame that misses its display deadline (16.7 ms at 60 Hz, 8.3 ms at 120 Hz), so the screen shows the previous frame again and the user sees a stutter.
How to measure
While developing: a system trace in Perfetto or the Android Studio profiler. The frame timeline marks janky frames, and you see which call overran.
Quick check: adb shell dumpsys gfxinfo <package> prints the share of janky frames and percentiles.
In CI: Macrobenchmark with FrameTimingMetric reports frame duration and overrun at P50–P99 for a scripted scroll.
In production: the JankStats library reports per-frame data from real devices, and Play Vitals shows slow frames (over 16 ms) and frozen frames (over 700 ms).
Usual causes
Work on the main thread during scroll: disk reads, JSON parsing, bitmap decoding.
Expensive layout: deep or nested view hierarchies measured twice, heavy onBindViewHolder.
In Compose, recomposing too much, for example reading a fast-changing state high in the tree.
GC pauses from allocating in draw and bind paths.
Code running interpreted on first launch, which a baseline profile fixes.
RenderThread and GPU cost: overdraw, large texture uploads, blur.
Overdraw is the GPU painting the same pixel several times in one frame (a window background, under a screen background, under a card), and on a cheap phone that wasted fill rate eats the frame budget.
How to spot it
Developer options → Debug GPU overdraw. True colour means no overdraw, blue 1 extra layer, green 2, pink 3, red 4 or more. Large red areas are the problem.
Check on the cheapest device you support. Overdraw is a cost per pixel, so it is invisible on a flagship and dominant on a budget phone.
What to remove
Stacked backgrounds: the theme's windowBackground, the root layout's background and each child's background often paint the same area. Let one opaque surface own it; in Compose, one Surface at the root of the screen.
The window background: clear it with window.setBackgroundDrawable(null) only when your content is opaque edge to edge, or you will see black flashes.
Deep hierarchies: ConstraintLayout instead of nested LinearLayouts with weights, <merge> for included layouts, ViewStub for parts that are rarely shown. Fewer views means less to measure and draw.
Hidden content: views fully covered by an opaque dialog or sheet still draw. Hide them, and keep translucent scrims to the area they actually cover.
A cold start creates the process, the Application and the first Activity from scratch; a warm start reuses a live process but recreates the Activity; a hot start only brings an existing Activity to the front. Cold start is the slowest and the one to optimise.
How to measure
Logcat prints Displayed com.app/.MainActivity: +850ms, the time to initial display.
adb shell am start -W -S com.app/.MainActivity force-stops the app first and reports TotalTime for a cold launch.
Macrobenchmark with StartupTimingMetric and StartupMode.COLD gives time to initial and full display over many runs.
Play Vitals flags excessive slow starts: cold 5 s or more, warm 2 s, hot 1.5 s.
How to speed it up
**Lean Application.onCreate: initialise only what the first frame needs; defer SDKs with lazy injection or until after the first frame.
ContentProviders**: every library provider is created before Application.onCreate. App Startup merges library initializers into one provider and lets you remove or delay them.
No blocking on the first frame: show cached data or a placeholder, then call reportFullyDrawn() (ReportDrawnWhen in Compose) when real content is visible, so time to full display is measured.
Baseline profile: startup code is compiled at install instead of interpreted.
Splash screen: installSplashScreen(); keep it on screen only while data is truly required.
A Baseline Profile is a list of classes and methods shipped inside the app that ART compiles ahead of time at install, so startup and the first scrolls run compiled code instead of being interpreted and JIT-compiled on the fly.
Why it matters: without a profile, the first launch is the slowest the app will ever be: code is interpreted, then JIT-compiled, and fully compiled only later in the background. Google reports about 30% faster code execution from the first launch.
How you create one: add the androidx.baselineprofile Gradle plugin and a profile generator module (Android Studio has a template). A BaselineProfileRule test drives the critical journeys: launch, scroll the main list, open a detail screen. ./gradlew :app:generateBaselineProfile runs it on a device or emulator (API 33+, or rooted API 28+) and writes the profile into the app module.
Startup profile: includeInStartupProfile = true also lets AGP lay out the DEX so startup classes sit together and fewer pages are loaded.
How you prove it: a Macrobenchmark with StartupTimingMetric, comparing CompilationMode.None() against CompilationMode.Partial() on a release build. Trust the median over many runs, not a feeling.
What it does not replace: Play also builds Cloud Profiles from real users, but only some time after a release; your profile covers day one. Compose and other Jetpack libraries ship their own profiles; yours adds your app's code.
Regenerate it when the critical journeys change; a stale profile precompiles code nobody runs.
R8 shrinks, optimizes and obfuscates your code (and, with isShrinkResources, your resources) in one pass when you build a release. Keep rules tell it which classes and members must survive unchanged because something finds them by name at runtime.
What it does
Shrinking: starts from entry points (manifest components and keep rules) and removes every class, method and field nothing reaches.
Optimization: inlining, class merging, dead-code removal. Full mode, the more aggressive one, is the default since AGP 8.0.
Obfuscation: renames what survives to short names; mapping.txt maps them back.
R8 replaced ProGuard as the default long ago and reads the same rule syntax, which is why the files are still called proguard-rules.pro.
What breaks when rules are wrong
Too few rules: anything resolved by name at runtime: Gson field names (fields silently come back null), Class.forName, JNI method names. The symptom is a crash or wrong data only in release: ClassNotFoundException, NoSuchMethodException.
Too broad rules: -keep class com.app.** { *; } quietly turns off shrinking and obfuscation for the whole app.
Libraries usually ship consumer rules; code-generating serializers such as kotlinx.serialization or Moshi codegen need nothing extra.
Working with it
Write narrow -keep rules or use @Keep, run QA on the minified release build, ask -whyareyoukeeping when something survives unexpectedly, and upload mapping.txt to Play and Crashlytics so production stack traces are readable.
Ship an Android App Bundle, turn on R8 with resource shrinking, and find the heaviest parts with APK Analyzer: usually native libraries, images or one oversized dependency.
The wins, roughly in order of payoff
Ship an Android App Bundle: Play builds per-device splits, so a user downloads one ABI, one screen density and their languages instead of all of them. With native libraries this alone often halves the download.
Turn on R8 and resource shrinking: isMinifyEnabled and isShrinkResources. Overly broad keep rules quietly undo both.
Native libraries: strip debug symbols from release .so files and upload them to Play separately (ndk.debugSymbolLevel), so native crashes stay readable.
Images: WebP (AVIF only on API 31+) instead of PNG and JPEG, vector drawables for icons.
Dependencies: the DEX view shows size per package. Pulling in a whole SDK for one helper function is very common.
Languages: for APK builds, androidResources.localeFilters keeps only the languages you actually translate; libraries ship dozens.
Play Feature Delivery: for a large, genuinely optional feature, an on-demand module. Not worth the complexity to save a megabyte.
Keep two numbers apart: the download size shown on the store listing, which is what install conversion follows, and the APK size on disk. Check a single device's download with bundletool build-apks --connected-device.
Battery complaints almost always come from work the app does while nobody is looking: wakeups, polling, location, or a wake lock that was never released.
Where the drain comes from
A partial wake lock held longer than the work needs, or a foreground service with no reason to be running.
Polling instead of push. Each wakeup pulls the cellular radio out of idle, and the radio stays powered for seconds after a tiny request.
Location at high accuracy and high frequency, worst of all in the background.
Unbatched work: many alarms at random times instead of one job the system can group with others.
Sensors, Bluetooth scans or media sessions left running after the screen turns off.
How to find it
Play Console Vitals reports excessive wakeups, excessive partial wake locks and background network use, per device model.
adb shell dumpsys batterystats --reset, run the scenario, dump again, and load the result into Battery Historian for a timeline.
adb shell dumpsys deviceidle force-idle and am set-standby-bucket let you test Doze and restricted buckets on purpose.
How to fix it
Deferrable work goes to WorkManager with constraints, so the system batches it.
Replace polling with FCM; keep high-priority messages for things the user must see now.
Exact alarms are for user-facing events only. SCHEDULE_EXACT_ALARM is denied by default for most new installs on Android 14+.
Batch analytics instead of sending one request per event.
Work with Doze and App Standby instead of asking for a battery-optimization exemption, which Play reviews.
They measure three scales: one function, one user journey, and one real user's session. Lab numbers are only meaningful on a release-like build, on a real device, compared across many runs.
Microbenchmark (benchmark-junit4) runs a block of code in a loop with warmup and clock stabilisation and reports time per iteration. It answers "is this parser faster than that one" and says nothing about frames or startup.
Macrobenchmark (benchmark-macro-junit4) drives the installed app from a separate test process and reports StartupTimingMetric, FrameTimingMetric, TraceSectionMetric, MemoryUsageMetric and PowerMetric. It answers "how long is cold start" and "how janky is this scroll", and it also generates baseline profiles.
JankStats is a library you ship. It reports frame durations from real devices, tagged with the screen or state you attach, so you learn which screen janks on your users' hardware.
Custom trace sections: trace("name") { ... } from androidx.tracing puts your code on the Perfetto timeline, and TraceSectionMetric measures it in CI. <profileable android:shell="true" /> makes a release build traceable without making it debuggable.
Play Vitals and Firebase Performance are the aggregated production view: ANR rate, startup distribution, slow and frozen frames.
Making the numbers mean something
Release-shaped build: minified and profileable, never debuggable; a debug build is several times slower and its numbers are fiction.
**Explicit CompilationMode**: None() is the worst case, Partial() the baseline-profile case, Full() a state no real user is in.
Real device, stable clocks: thermal throttling and emulator noise easily hide the regression you are looking for.
Median and P90 over many iterations, never one run.
CI on one fixed device model, compared with the previous commit, so a regression is tied to the change that caused it.
A frame is a pipeline of the main thread, the RenderThread and the GPU, and each stage has to finish inside one vsync interval (8.3 ms at 120 Hz). Main-thread time is only the first stage; the RenderThread or the GPU can be the one that misses.
What one frame really is
A vsync signal wakes Choreographer, which runs its callbacks in a fixed order: input, animation, insets animation, traversal (measure, layout, draw), commit.
draw does not draw pixels. It records a display list of RenderNodes.
The RenderThread syncs that display list, uploads new textures, issues GPU commands and queues the finished buffer.
SurfaceFlinger composites the queued buffer at a later vsync. The frame you are producing is shown one or two vsyncs from now.
So a freshly decoded bitmap, a blur or several offscreen layers can blow the frame on the RenderThread while the main thread looks innocent.
Reading it instead of guessing
Perfetto's frame timeline (Android 12+) shows an expected and an actual timeline per frame and says whether your app or SurfaceFlinger missed.
Window.addOnFrameMetricsAvailableListener gives per-frame numbers on any device: LAYOUT_MEASURE_DURATION and DRAW_DURATION for the main thread; SYNC_DURATION, COMMAND_ISSUE_DURATION and SWAP_BUFFERS_DURATION (and GPU_DURATION on API 31+) for the RenderThread and GPU; plus UNKNOWN_DELAY_DURATION.
A large UNKNOWN_DELAY_DURATION means the frame started late. Often that is buffer stuffing: one missed frame fills the buffer queue and every following frame waits for a free buffer, so the app feels slower for a while after a single hiccup.
You are not racing a fixed clock; you are feeding a pipeline whose slowest stage sets the frame rate.
refresh() holds the lock for the whole network call, and current() needs the same lock. If the main thread binds the list during a refresh, it waits for the request to finish: seconds of frozen UI, and an ANR once input goes unhandled for about 5 s.
Why it happens: synchronized makes the reader wait for the writer, whatever the writer is doing inside. The main thread does no I/O itself; it is blocked behind a thread that does. A 2 s fetch makes this current() take 2 s.
How the fix works: fetch first, with no lock held, then publish the result with one assignment to a @Volatile field. Swapping a reference to an immutable list is atomic, so a reader gets the old list or the new one, never a half-built one, and never waits.
If you do need a lock (to merge with the old data, say), hold it only for the in-memory part. Never call the network, the disk or a foreign callback while holding a lock the main thread can take. A ReentrantReadWriteLock does not save you: readers still wait for a writer.
How to catch it: StrictMode stays silent, because the main thread touches no disk or network. The ANR trace in Play Vitals shows "main" ... Blocked with waiting to lock <0x...> held by thread NN; follow that thread to the fetch. In a Perfetto trace, ART marks the wait as a Lock contention on ... slice on the main thread.
The low-memory killer looks at the whole process footprint, and native allocations, graphics memory, code and thread stacks all count toward it while none of them appear on the Java heap graph.
Read the App Summary
adb shell dumpsys meminfo <package> ends with a summary most people skip:
Java Heap: your objects, capped by ActivityManager.memoryClass (commonly 192–512 MB). Exceeding this cap is what throws OutOfMemoryError.
Native Heap: bitmap pixels (there since Android 8), ML models, anything a native library allocated.
Graphics: textures, surfaces and HARDWARE bitmaps. Often the biggest number and invisible to the Java profiler.
Code: mapped .so, .dex, .oat and .art files, mostly reclaimable.
PSS shares pages between the processes that use them; RSS counts them fully. ApplicationExitInfo reports both for a process that was actually killed (see the ApplicationExitInfo question for how to read it).
Two failures, two fixes
OutOfMemoryError is the Java heap cap. android:largeHeap="true" raises it and is nearly always wrong: it does nothing for native or graphics memory, makes collections longer, and makes the process a bigger target for the killer.
A low-memory kill is total footprint under system pressure. Shrinking the Java heap does not help when the memory sits in Graphics or Native.
Profile native memory with the Memory Profiler's native allocation recording or Perfetto's heapprofd, free caches in onTrimMemory, and confirm with ApplicationExitInfo from real kills rather than a lab session where nothing was under pressure.
The system records how your previous process ended, and ApplicationExitInfo (API 30+) lets you read that record on the next launch.
Call getHistoricalProcessExitReasons(packageName, 0, 5) early in Application.onCreate, log the newest entry and attach it to your crash reporter. The reasons you will see:
REASON_LOW_MEMORY: the low-memory killer reclaimed the backgrounded process. Very common and not a bug in itself.
REASON_ANR: getTraceInputStream() gives you the ANR trace.
REASON_CRASH and REASON_CRASH_NATIVE: the second is a native crash your JVM crash handler never sees.
REASON_USER_REQUESTED: the user swiped the app away. Nothing to fix.
Others: REASON_EXCESSIVE_RESOURCE_USAGE, REASON_PERMISSION_CHANGE, REASON_FREEZER.
Also log pss, rss and importance: a kill at low importance with a large footprint is a memory problem, not a restore bug.
Most of these reports are not crashes. Being killed in the background is normal Android behaviour; what users notice is lost state. So the fix usually lives in state restoration: keep screen state in SavedStateHandle and rememberSaveable, restore the navigation back stack, and never keep the only copy of user input in an Activity field.
Reproduce it on purpose: put the app in the background and run adb shell am kill <package>. That kills the process but keeps the task, exactly what users hit. Force-stop is a different, harsher path and not the scenario to test.
To be killed less often, be smaller: trim caches in onTrimMemory, do not hold large bitmaps or parsed payloads in the background, and do not keep a foreground service you do not need.
Track what users feel: crash and ANR rates, startup time, slow and frozen frames, memory kills and battery. Play's core vitals are the user-perceived crash rate (bad above 1.09%) and user-perceived ANR rate (bad above 0.47%), plus 8% on any single phone model.
Play core vitals
User-perceived crash rate: share of daily active users who had a crash while using the app. Bad behaviour threshold: 1.09% overall, 8% per device model.
User-perceived ANR rate: same idea for ANRs the user noticed. Threshold: 0.47% overall, 8% per device model.
Above the threshold Play can lower the app's visibility, and above the per-device one it can show a warning on the store listing for that model.
Other metrics worth a dashboard
Startup: time to initial and full display, P50 and P90. Vitals flags slow cold (5 s+), warm (2 s+) and hot (1.5 s+) starts.
Rendering: slow frames (over 16 ms) and frozen frames (over 700 ms) per screen, from JankStats or Firebase Performance.
Memory: low-memory kills from ApplicationExitInfo, OOM crash rate.
Battery: excessive wakeups, excessive partial wake locks, background network usage.
How to use them
Look at percentiles and per-device slices, not averages; the long tail lives on low-end phones.
Split by app version, roll out in stages and halt a rollout when vitals regress.
Lab checks (Macrobenchmark in CI) catch regressions before release; field data confirms what actually shipped.
Android 15+ devices can run with 16 KB memory pages instead of 4 KB, and since November 2025 Google Play requires apps targeting Android 15+ to support them. For apps with native .so files that means rebuilding them with 16 KB ELF alignment.
Who is affected
An app with only Kotlin or Java code needs nothing; ART handles the page size.
Any app with native libraries is affected, including libraries pulled in by SDKs: image and video codecs, ML runtimes, crypto, cross-platform engines. A library whose LOAD segments are aligned to 4 KB fails to load on a 16 KB device, which usually means a crash at startup.
Native code that hard-codes 4096 as the page size (for mmap offsets or allocator alignment) breaks too. Ask the system with sysconf(_SC_PAGESIZE).
Making it compliant
AGP 8.5.1+ stores uncompressed native libraries 16 KB-aligned inside the APK.
NDK r28+ builds 16 KB-aligned libraries by default. With r27, set ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES; older NDKs need the linker flag -Wl,-z,max-page-size=16384.
Third-party SDKs must ship aligned binaries, so updating them is often the slowest part of the work.
Verifying it
The APK Analyzer in Android Studio and the Play Console bundle explorer flag non-compliant libraries.
zipalign and llvm-objdump check the package and each library from the command line.
Test on an Android 15+ emulator image with 16 KB pages, or on a Pixel with the developer option that boots the 16 KB kernel.