DEV Community

Cover image for Day 3/100: Activity Lifecycle — The Diagram You've Seen 100 Times
Hoang Son
Hoang Son

Posted on

Day 3/100: Activity Lifecycle — The Diagram You've Seen 100 Times

This is Day 3 of my 100 Days to Senior Android Engineer series. Each post: what I thought I knew → what I actually learned → interview implications.


🔍 The concept

Every Android developer has seen this diagram:

          onCreate()
              ↓
          onStart()
              ↓
          onResume()  ←──────────────────────┐
              ↓                              │
         [Activity running]                  │
              ↓                              │
          onPause()                          │
              ↓                         onRestart()
          onStop()   ──────────────────────→ │
              ↓
          onDestroy()
Enter fullscreen mode Exit fullscreen mode

You could draw this in your sleep. I certainly could.

The problem is that this diagram shows the happy path. It doesn't show what happens in the three scenarios that actually matter in production — and that every senior interview will eventually probe.


💡 What I thought I knew

My mental model was: callbacks fire in a predictable sequence, each one is the right place to do certain things (init in onCreate, release in onDestroy), and as long as I follow the pattern, everything works.

That model is mostly right. Mostly.


😳 What I actually learned

The diagram has a ghost: the back stack

The standard diagram shows a single Activity in isolation. But your app never has just one Activity in isolation — it has a back stack, and the lifecycle behaves differently depending on why a callback fires.

Consider onStop(). It gets called in two very different situations:

Situation A: User presses Home
    → Activity goes to background
    → onPause() → onStop()
    → Activity is still in memory (probably)
    → User comes back → onRestart() → onStart() → onResume()

Situation B: User navigates to another Activity
    → Current Activity partially covered
    → onPause() → onStop()
    → User presses Back → onRestart() → onStart() → onResume()

Situation C: System kills the process (low memory)
    → onPause() → onStop() → [process killed, no onDestroy()]
    → User comes back → onCreate() with savedInstanceState Bundle
Enter fullscreen mode Exit fullscreen mode

Situation C is the one that breaks apps. onDestroy() is not guaranteed to be called. If you're releasing resources, closing connections, or saving state in onDestroy(), you have a bug waiting to happen under memory pressure.


The configuration change trap

Rotate the screen. What happens?

onPause()
onStop()
onDestroy()   ← called this time, because it's a controlled teardown
onCreate()    ← brand new instance
onStart()
onResume()
Enter fullscreen mode Exit fullscreen mode

The Activity is destroyed and recreated. Which means:

  • Any data stored in instance variables is gone
  • Any network request in flight is cancelled (if tied to the Activity's scope)
  • Any UI state that isn't explicitly saved is reset

Here's a pattern I've seen in codebases more than I'd like:

class ProfileActivity : AppCompatActivity() {
    private var userProfile: UserProfile? = null  // 💀 gone on rotation

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Fetch every time, including on rotation
        fetchUserProfile()
    }

    private fun fetchUserProfile() {
        // Network call fires again on every rotation
        viewModel.loadProfile(userId)
    }
}
Enter fullscreen mode Exit fullscreen mode

The fix isn't complicated — it's using ViewModel correctly:

class ProfileActivity : AppCompatActivity() {
    // ViewModel survives configuration changes
    private val viewModel: ProfileViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ViewModel already has data if we're recreating after rotation
        // loadProfile() is called inside ViewModel's init{} or handled idempotently
        observeViewModel()
    }
}
Enter fullscreen mode Exit fullscreen mode

The ViewModel survives configuration changes because it's scoped to the ViewModelStore, which is retained across recreations. The Activity is recreated; the ViewModel is not.


onPause() is your last guarantee

This is the one that surprises the most people.

onPause() is the only callback guaranteed to be called before your process can be killed.

Not onStop(). Not onDestroy(). Just onPause().

From the docs:

"If the system needs to recover memory in an emergency, it might not call onStop() and onDestroy()."

This has a direct consequence on where you save critical state:

override fun onPause() {
    super.onPause()
    // ✅ Save anything critical HERE — this is your last guaranteed checkpoint
    saveDraftMessage()
    releaseCamera()  // release exclusive resources here, not in onStop
}

override fun onStop() {
    super.onStop()
    // ✅ Safe for non-critical cleanup that can tolerate being skipped under pressure
    stopLocationUpdates()
}

override fun onDestroy() {
    super.onDestroy()
    // ✅ Cleanup that you'd expect in a clean shutdown
    // ❌ Do NOT rely on this for anything critical
}
Enter fullscreen mode Exit fullscreen mode

There's also a performance implication: onPause() blocks the transition to the next Activity. Anything slow in onPause() directly delays how quickly the new screen appears. Keep it fast.


The two-Activity transition sequence

This is a classic interview question, and the answer is not what most people expect.

What's the callback order when Activity A starts Activity B?

// Most people answer:
A.onPause()
A.onStop()
B.onCreate()
B.onStart()
B.onResume()

// Actual order:
A.onPause()        A pauses FIRST
B.onCreate()       B starts creating
B.onStart()
B.onResume()       B is now visible
A.onStop()         A stops AFTER B is running
Enter fullscreen mode Exit fullscreen mode

A.onStop() comes after B.onResume(). This matters because it means A is still technically "paused but not stopped" while B initializes. If you're holding an exclusive resource (camera, audio focus) and releasing it in onStop() instead of onPause(), Activity B will try to acquire it before you've released it.


🧪 The mental model that stuck

Instead of memorizing the diagram, I now ask two questions for each callback:

1. Can this be the last callback that runs?

Callback Can be last before kill?
onPause() ✅ Yes — save critical state here
onStop() ⚠️ Usually, but not guaranteed
onDestroy() ❌ Not guaranteed

2. How many times will this run in a session?

Scenario onCreate onResume
Normal launch
Home → Back to app
Rotate screen
Rotate 3 times

If you're doing expensive initialization in onResume() instead of onCreate(), every rotation and every return from background triggers it again. That's a real performance bug in disguise.


❓ The three interview questions

Question 1 — Basic:

"When does onDestroy() get called, and can you rely on it always being called?"

No. Under memory pressure, the process is killed without onDestroy(). Save critical state in onPause().


Question 2 — Mid-level:

"A user starts filling out a form, receives a phone call, and comes back. How do you ensure their input is preserved?"

// Option A: ViewModel (survives configuration change, lost on process death)
class FormViewModel : ViewModel() {
    val formState = MutableStateFlow(FormState())
}

// Option B: onSaveInstanceState (survives process death, limited to Bundle size)
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putString("draft_input", binding.editText.text.toString())
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    savedInstanceState?.getString("draft_input")?.let {
        binding.editText.setText(it)
    }
}

// Option C: SavedStateHandle (best of both — ViewModel + process death survival)
class FormViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    var draftInput: String
        get() = savedStateHandle["draft_input"] ?: ""
        set(value) { savedStateHandle["draft_input"] = value }
}
Enter fullscreen mode Exit fullscreen mode

The senior answer explains all three options, their tradeoffs, and reaches for SavedStateHandle as the modern solution — because it survives both configuration changes and process death.


Question 3 — Senior:

"Activity A launches Activity B. In Activity B, the user performs an action that should update a result visible in Activity A. How do you handle this, and what lifecycle considerations apply?"

The legacy approach — startActivityForResult() — is deprecated. The modern answer:

// In Activity A — register before onCreate completes
private val launcher = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) { result ->
    if (result.resultCode == RESULT_OK) {
        val data = result.data?.getStringExtra("updated_value")
        viewModel.handleUpdate(data)
    }
}

// Launch Activity B
launcher.launch(Intent(this, ActivityB::class.java))

// In Activity B — send result back
setResult(RESULT_OK, Intent().putExtra("updated_value", newValue))
finish()
Enter fullscreen mode Exit fullscreen mode

The lifecycle consideration: the result callback fires during onResume() of Activity A. If you're observing a StateFlow in onStart() with repeatOnLifecycle, you don't need the callback at all — just update the ViewModel from B and let A observe the change.


What surprised me revisiting this

The lifecycle diagram gives you the what but not the why. Going back through the documentation carefully, three things stood out:

  1. onPause() blocking — I knew it was called, but I hadn't internalized that slow onPause() directly delays the next screen appearing. I've definitely shipped code that violated this.

  2. The two-Activity transition order — I knew A pauses before B starts, but I hadn't thought through the implication for exclusive resources until I re-read the ordering carefully.

  3. SavedStateHandle being the real answer — I'd used onSaveInstanceState for years and ViewModel for a few more, but SavedStateHandle genuinely makes both feel like workarounds in comparison.


Tomorrow

Day 4 → Fragment Lifecycle vs Activity Lifecycle — they look similar on a diagram, and that's exactly why people keep getting them wrong.

What's a lifecycle bug you've shipped to production? Drop it in the comments — no judgment, we've all been there.


Day 2: The 4 Android Components — What Senior Devs Get Wrong

Top comments (0)