DEV Community

Cover image for Day 8/100: Process Death — What Actually Happens When Android Kills Your App
Hoang Son
Hoang Son

Posted on

Day 8/100: Process Death — What Actually Happens When Android Kills Your App

This is Day 8 of my 100 Days to Senior Android Engineer series. Week 2 theme: Process & Memory. Each post: what I thought I knew → what I actually learned → interview implications.


🔍 The concept

Your app is running. The user presses Home. Five minutes later, they tap your icon again.

From the user's perspective, nothing happened. They just switched apps briefly.

From Android's perspective: your process may have been killed and restarted from scratch. Your Application.onCreate() ran again. Every singleton was re-initialized. Every in-memory state is gone.

And if your app doesn't handle this correctly, the user walks into a broken screen — a detail page with no data, a checkout flow mid-step with no cart, or worst of all, a crash.

Process death is the bug that never reproduces on a developer's machine.


💡 What I thought I knew

I knew process death existed. I knew onSaveInstanceState was the mechanism for handling it. I knew ViewModel survives configuration changes but not process death.

What I hadn't internalized was how frequently it actually happens and how many common patterns silently break under it.


😳 What I actually learned

Android's process priority hierarchy

Android kills processes based on a priority system. Understanding this explains when your process is at risk:

Priority 1 — Foreground process
  Active Activity (onResume), running ForegroundService,
  BroadcastReceiver executing onReceive()
  → Almost never killed. System is critically low if this happens.

Priority 2 — Visible process
  Activity visible but not focused (onPause),
  Service bound to a visible/foreground Activity
  → Killed only if Priority 1 processes need resources.

Priority 3 — Service process
  Service running in background (startService, not foreground)
  → Killed when Priority 1 and 2 need more resources. Common.

Priority 4 — Cached process (background)
  App in background, no active components
  → Killed freely. This is where most apps live after Home press.
  → Killed in LRU order — least recently used first.

Priority 5 — Empty process
  No active components, kept alive for startup performance
  → First to be killed.
Enter fullscreen mode Exit fullscreen mode

The moment your user presses Home and your app has no foreground service running, you drop to Priority 4. On a device with 4GB RAM and 30 apps installed, your process might survive for hours. On a device with 2GB RAM and aggressive battery optimization (common in Southeast Asian markets), it might survive for 30 seconds.


What "process death" actually looks like

When Android kills your process:

Normal flow (no process death):
  User presses Home
    → onPause() → onStop()       ← callbacks fire
    → Process stays in memory
  User returns
    → onRestart() → onStart() → onResume()
    → Same objects, same state

Process death flow:
  User presses Home
    → onPause() → onStop()       ← callbacks still fire
    → [some time passes]
    → Android kills the process  ← no callbacks, no warning
  User returns
    → NEW process starts
    → Application.onCreate()     ← fresh start
    → Activity.onCreate(bundle)  ← bundle from onSaveInstanceState
    → UI restored from bundle
    → But: ViewModel is GONE, singletons are GONE, in-memory state is GONE
Enter fullscreen mode Exit fullscreen mode

The key insight: the Activity back stack survives, but your app's memory does not.

Android keeps the back stack metadata. It knows you were on the detail screen. It creates a new process, creates a new Activity, and passes the saved Bundle — but everything your app was holding in memory is gone.


The specific patterns that break silently

Pattern 1: ViewModel loading data only on first creation

class OrderDetailViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    // ❌ Loaded once, never reloaded after process death
    private val orderId = savedStateHandle.get<String>("order_id")

    init {
        // This runs on first creation AND after process death recreation
        // So this is actually fine — but many devs write it wrong:
        loadOrder(orderId)
    }
}

// The broken version:
class OrderDetailViewModel : ViewModel() {
    private var hasLoaded = false  // 💀 this flag is reset after process death

    fun loadIfNeeded(orderId: String) {
        if (hasLoaded) return  // After process death, hasLoaded = false
        hasLoaded = true       // so this check doesn't save you
        loadOrder(orderId)
    }
}
Enter fullscreen mode Exit fullscreen mode

After process death, hasLoaded starts as false again — the check is meaningless. The init block approach is safer because it always runs on ViewModel creation, which happens both on first launch and after process death.


Pattern 2: Passing objects through ViewModel without persistence

// ❌ User object fetched once, stored only in memory
class ProfileViewModel : ViewModel() {
    private val _user = MutableStateFlow<User?>(null)

    fun setUser(user: User) {
        _user.value = user  // Gone after process death
    }
}

// Activity passes user from login:
viewModel.setUser(loggedInUser)

// Navigate to ProfileActivity → SettingsActivity → EditActivity
// User presses Home, process killed
// User returns to EditActivity
// EditActivity.onCreate() called, ViewModel recreated, _user = null
// 💀 EditActivity tries to show user data that doesn't exist
Enter fullscreen mode Exit fullscreen mode

The fix: persist what matters. Either through SavedStateHandle for lightweight data, or by re-fetching from the repository (which reads from local database or cache) on every ViewModel creation.

class ProfileViewModel(
    savedStateHandle: SavedStateHandle,
    private val userRepository: UserRepository
) : ViewModel() {

    // The userId comes from navigation args — survives process death via Bundle
    private val userId: String = savedStateHandle["user_id"]
        ?: throw IllegalStateException("user_id required")

    // Always load from repository — handles both first launch and process death
    val user: StateFlow<User?> = userRepository.getUser(userId)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Navigation state assumptions

// ❌ Assuming in-memory state is valid when a screen is shown
class CheckoutActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Cart is held by a singleton — reset after process death
        val cartItems = CartManager.instance.items

        if (cartItems.isEmpty()) {
            // This can happen even if the user was mid-checkout
            // because CartManager was re-created empty
            navigateToHome()  // User loses their cart progress
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Singletons are re-initialized after process death. CartManager.instance.items will be empty. The checkout screen defensively redirects to Home, losing the user's cart.

The fix: persist cart state to local storage. CartManager should restore from database on initialization, not start empty.


How to test for process death

This is the part most developers skip — because process death never happens during development.

Method 1: Android Studio

1. Run your app
2. Navigate to a deep screen
3. Press Home
4. In Android Studio: Run → Terminate Application (the red square)
   OR use: adb shell am kill <your.package.name>
5. Tap the app icon in the Recents screen (not the launcher icon)
6. Observe: does the screen restore correctly?
Enter fullscreen mode Exit fullscreen mode

The difference between step 5 and 6 is crucial. Tapping the launcher icon always starts fresh. Tapping from Recents triggers the process death restoration path.

Method 2: Don't keep activities (developer option)

Settings → Developer Options → Don't keep activities → ON
Enter fullscreen mode Exit fullscreen mode

With this enabled, every time you leave an Activity (even just switching apps briefly), it's destroyed. This is an aggressive simulation — more aggressive than real process death — but it catches most of the same bugs.

Method 3: adb command for precise control

# Kill the process while keeping the task alive
adb shell am kill com.your.package.name

# Then tap from Recents — triggers full process death restoration
Enter fullscreen mode Exit fullscreen mode

The correct mental model

Think of your app in two layers:

Layer 1: Process memory (volatile)
  ViewModels, singletons, in-memory caches,
  static fields, Application-level state
  → Lives as long as the process
  → Completely reset on process death

Layer 2: Persistent state (durable)
  onSaveInstanceState Bundle (UI state)
  SavedStateHandle (ViewModel state)
  Room/DataStore/SharedPreferences (app data)
  → Survives process death
  → What the OS uses to reconstruct your UI
Enter fullscreen mode Exit fullscreen mode

For every piece of state in your app, ask: which layer does this live in? If it lives in Layer 1 but the UI depends on it being there when the user returns, you have a process death bug waiting to happen.


🧪 The decision framework

State needs to survive...

Configuration change only (rotation)?
  → ViewModel (no SavedStateHandle needed)

Configuration change + process death?
  → SavedStateHandle in ViewModel
  → Keep it small: IDs, selections, scroll position
  → Not full objects — just enough to re-fetch or reconstruct

App session (cart, auth token, user preferences)?
  → Local database (Room) or DataStore
  → Re-load into ViewModel on creation

Nothing — ephemeral UI state?
  → Let it reset. Not everything needs to survive.
Enter fullscreen mode Exit fullscreen mode

❓ The interview questions

Question 1 — Mid-level:

"What's the difference between ViewModel surviving rotation and process death?"

ViewModel is stored in the ViewModelStore, which is retained across configuration changes by the NonConfigurationInstance mechanism. But when the process is killed, the ViewModelStore is destroyed along with everything else in memory. A fresh process creates a fresh ViewModel.

SavedStateHandle bridges this gap — it's backed by the same Bundle as onSaveInstanceState, so it survives process death and is automatically restored into the new ViewModel instance.


Question 2 — Senior:

"Your app has a multi-step checkout flow: Cart → Shipping → Payment → Confirmation. How do you design state management to handle process death at any step?"

// Each step's ViewModel reads from a shared CheckoutRepository
// which persists state to Room

class ShippingViewModel(
    savedStateHandle: SavedStateHandle,
    private val checkoutRepo: CheckoutRepository
) : ViewModel() {

    // Checkout ID from nav args — survives process death via Bundle
    private val checkoutId: String = savedStateHandle["checkout_id"]!!

    // Always loaded from repository — not stored in memory only
    val checkoutState: StateFlow<CheckoutState> = checkoutRepo
        .getCheckout(checkoutId)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CheckoutState.Loading)

    fun updateShippingAddress(address: Address) {
        viewModelScope.launch {
            // Persist immediately — survives process death
            checkoutRepo.updateShipping(checkoutId, address)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The key design decision: persist state to the database immediately on user action, not on screen exit. Waiting for onPause() to persist is risky — it can be skipped. Writing to Room on every meaningful action is safe and gives you process death resilience for free.


Question 3 — Senior:

"How does Android decide which processes to kill, and how does this affect your architecture decisions?"

The LRU cache + priority system means the most recently used apps survive longest. Apps with active foreground services are essentially immune. Background apps on low-memory devices can be killed within minutes.

Architecture implications:

  1. Don't rely on in-memory state surviving background time — especially for apps targeting emerging markets where low-RAM devices are common.

  2. Foreground services are the right tool for truly uninterruptible work — not a performance hack. Music playback, navigation, active file uploads should be foreground services because they actively communicate to the user that the app is doing something.

  3. Repository should always be the source of truth — not the ViewModel. The ViewModel is a UI-layer cache. The Repository is where durability lives.

  4. Test on low-end devices — process death behavior that never happens on a Pixel 8 happens constantly on a device with 3GB RAM and aggressive vendor battery optimization.


What surprised me revisiting this

  1. How frequently process death happens on real devices — especially in Southeast Asian markets where mid-range devices dominate and battery optimization is aggressive. I'd been testing primarily on high-end devices and missing a whole class of user-reported bugs.

  2. The Don't keep activities developer option being more useful than I gave it credit for. I used to think it was too aggressive to be realistic. Now I run with it on regularly during development.

  3. Writing to the database immediately on user action, not on exit — I'd been conflating "persist on pause" with "persist on change". The former is fragile; the latter is robust. A small but important mental shift.


Tomorrow

Day 9onSaveInstanceState vs ViewModel vs SavedStateHandle — three mechanisms for preserving state, each with different guarantees. Which one to reach for and when.

Have you ever gotten a bug report that was caused by process death but didn't know it at the time? I'd genuinely like to know — drop it in the comments.


Day 7: Week 1 Recap — 6 Android Fundamentals I Thought I Knew Cold

Top comments (0)