This is Day 9 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
After Day 8 on process death, the natural follow-up is: how do you actually handle it?
Android gives you three distinct mechanisms for preserving state across different lifecycle events:
-
onSaveInstanceState— the original, been there since API 1 -
ViewModel— survives configuration changes, introduced in 2017 -
SavedStateHandle— bridges the two, introduced in 2019
Each one has a different scope, different limitations, and is the right answer in different situations. Using all three interchangeably — or defaulting to one for everything — is a sign that the tradeoffs haven't been fully internalized.
💡 What I thought I knew
My mental model used to be:
-
ViewModelfor "real" data -
onSaveInstanceStatefor UI state like scroll position -
SavedStateHandleas a "betteronSaveInstanceStateinside ViewModel"
That's roughly right, but too vague to make good decisions. The nuances are where bugs live.
😳 What I actually learned
What each mechanism actually survives
The most important thing to understand first — before any code:
Event │ onSaveInstanceState │ ViewModel │ SavedStateHandle
─────────────────────────────┼─────────────────────┼───────────┼──────────────────
Screen rotation │ ✅ survives │ ✅ survives│ ✅ survives
App goes to background │ ✅ survives │ ✅ survives│ ✅ survives
Process killed (background) │ ✅ survives │ ❌ lost │ ✅ survives
User navigates Back (finish) │ ❌ cleared │ ❌ cleared │ ❌ cleared
App force-stopped by user │ ❌ cleared │ ❌ cleared │ ❌ cleared
The critical column is process death: ViewModel does not survive it, SavedStateHandle does.
And the critical row is user navigates Back: nothing survives it, nor should it. When the user explicitly exits a screen, that state should be gone.
onSaveInstanceState — the OG, still relevant
onSaveInstanceState serializes state into a Bundle that the OS persists. It's called before the Activity might be killed and passed back into onCreate() when the Activity is recreated.
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("search_query", binding.searchInput.text.toString())
outState.putInt("selected_tab", binding.tabLayout.selectedTabPosition)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
savedInstanceState?.let { bundle ->
binding.searchInput.setText(bundle.getString("search_query"))
binding.tabLayout.selectTab(
binding.tabLayout.getTabAt(bundle.getInt("selected_tab"))
)
}
}
What it's good for: lightweight UI state — which tab is selected, scroll position, text input contents, checkbox states. Things that describe how the UI looks, not what data it's showing.
What it's bad for:
- Large objects — the Bundle has a transaction size limit (~1MB, shared with IPC). Putting a list of 500 items in a Bundle risks a
TransactionTooLargeException. - Complex objects — everything must be
ParcelableorSerializable. Storing your domain model directly is fragile. - Logic —
onSaveInstanceStatelives in the View layer. State management logic shouldn't.
ViewModel — the right home for most state
ViewModel survives configuration changes because it's stored in ViewModelStore, which is retained via NonConfigurationInstance across recreations.
class SearchViewModel : ViewModel() {
// Survives rotation — not process death
private val _searchResults = MutableStateFlow<List<Product>>(emptyList())
val searchResults: StateFlow<List<Product>> = _searchResults.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
_isLoading.value = true
_searchResults.value = repository.search(query)
_isLoading.value = false
}
}
}
What it's good for: fetched data, UI state that comes from business logic, coroutine-launched operations, anything that would be expensive to re-fetch on every rotation.
What it's bad for: surviving process death. If your app is killed while the user is mid-search, _searchResults starts empty on restoration. Whether that's acceptable depends on the feature.
SavedStateHandle — the bridge
SavedStateHandle is injected into ViewModel and backed by the same Bundle mechanism as onSaveInstanceState. It survives process death. It also gives you a Flow/StateFlow API so it feels native to modern Android.
class SearchViewModel(
private val savedStateHandle: SavedStateHandle,
private val repository: SearchRepository
) : ViewModel() {
// This query survives process death
// If the user was searching "headphones" and the process was killed,
// they come back to a screen still searching "headphones"
var searchQuery: String
get() = savedStateHandle["search_query"] ?: ""
set(value) {
savedStateHandle["search_query"] = value
triggerSearch(value)
}
// This is also available as a Flow
val searchQueryFlow: StateFlow<String> =
savedStateHandle.getStateFlow("search_query", "")
val searchResults: StateFlow<List<Product>> = searchQueryFlow
.debounce(300)
.flatMapLatest { query ->
if (query.isBlank()) flowOf(emptyList())
else repository.search(query)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
What it's good for: IDs passed through navigation, user input that should survive process death (search query, form draft), selections and filters that define what the screen is showing.
What it's bad for: large objects — same Bundle size constraint as onSaveInstanceState. Store IDs, not full objects. Let the repository re-fetch the full data using the preserved ID.
The practical pattern: ID in Handle, data in ViewModel
This is the pattern that correctly combines all three mechanisms:
class OrderDetailViewModel(
savedStateHandle: SavedStateHandle,
private val orderRepository: OrderRepository
) : ViewModel() {
// ✅ orderId comes from navigation args, stored in SavedStateHandle
// Small string — survives process death, trivially serializable
private val orderId: String = savedStateHandle["order_id"]
?: throw IllegalStateException("order_id navigation arg required")
// ✅ Full Order object fetched from repository using the preserved ID
// Repository reads from Room cache first, then network if needed
// Not stored in SavedStateHandle — too large, and the repo can reconstruct it
val order: StateFlow<Result<Order>> = orderRepository
.getOrder(orderId)
.map { Result.success(it) }
.catch { emit(Result.failure(it)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Result.success(null))
// ✅ Lightweight UI state — selected tab, expanded sections
// Could go in ViewModel (rotation only) or SavedStateHandle (process death too)
// Here we use SavedStateHandle since it's cheap and the UX is better
var selectedTab: Int
get() = savedStateHandle["selected_tab"] ?: 0
set(value) { savedStateHandle["selected_tab"] = value }
}
The rule of thumb: store the minimum identifier in SavedStateHandle, fetch the full data using that identifier.
What about Views that save their own state?
Views with an id in XML automatically save and restore certain state — EditText text content, ScrollView scroll position, CheckBox checked state. This is handled by the View system's own onSaveInstanceState mechanism, separate from the Activity's.
<!-- This EditText saves its content automatically because it has an id -->
<EditText
android:id="@+id/search_input"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<!-- This one doesn't — no id -->
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
This is why giving Views meaningful IDs is important beyond just findViewById. No ID = no automatic state restoration.
In Compose, there's an equivalent: rememberSaveable vs remember.
// remember — survives recomposition only
var searchQuery by remember { mutableStateOf("") }
// rememberSaveable — survives recomposition AND process death
// Backed by the same Bundle mechanism
var searchQuery by rememberSaveable { mutableStateOf("") }
The Compose + ViewModel combination
In a Compose screen with a ViewModel, the responsibility split is:
@Composable
fun SearchScreen(
viewModel: SearchViewModel = viewModel()
) {
// ✅ Collect ViewModel state — survives rotation, re-fetched after process death
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
// ✅ rememberSaveable for ephemeral UI state not worth putting in ViewModel
// e.g., whether a filter panel is expanded
var isFilterExpanded by rememberSaveable { mutableStateOf(false) }
// ❌ Don't use remember for state the user would be frustrated to lose
// var searchQuery by remember { mutableStateOf("") }
// ✅ Query lives in ViewModel/SavedStateHandle — survives process death
val searchQuery by viewModel.searchQueryFlow.collectAsStateWithLifecycle()
SearchScreenContent(
query = searchQuery,
results = searchResults,
isLoading = isLoading,
isFilterExpanded = isFilterExpanded,
onQueryChange = viewModel::onQueryChange,
onFilterToggle = { isFilterExpanded = !isFilterExpanded }
)
}
🧪 The decision framework
What are you preserving?
Navigation argument (ID, type, mode)?
→ SavedStateHandle (via nav args, automatic with Navigation Component)
User input that defines the screen (search query, form draft)?
→ SavedStateHandle in ViewModel
Fetched data (list of items, user profile)?
→ ViewModel StateFlow, re-fetched from repository after process death
→ Repository caches to Room for offline resilience
Ephemeral UI state (expanded sections, selected tab)?
→ ViewModel (rotation only) — acceptable UX to reset after process death
→ SavedStateHandle — if reset after process death would be jarring
Transient visual state (scroll position)?
→ View's own save state (XML id) or rememberSaveable in Compose
Nothing — should reset on every entry?
→ Don't save it. Let it be ephemeral.
❓ The interview questions
Question 1 — Mid-level:
"A user fills out a 5-field registration form and gets a phone call. They come back 10 minutes later. How do you ensure their form data is still there?"
class RegistrationViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// Each field backed by SavedStateHandle — survives process death
var firstName: String
get() = savedStateHandle["first_name"] ?: ""
set(value) { savedStateHandle["first_name"] = value }
var lastName: String
get() = savedStateHandle["last_name"] ?: ""
set(value) { savedStateHandle["last_name"] = value }
var email: String
get() = savedStateHandle["email"] ?: ""
set(value) { savedStateHandle["email"] = value }
// Alternatively — expose as StateFlow for Compose
val firstNameFlow = savedStateHandle.getStateFlow("first_name", "")
}
The user gets a phone call → app goes to background → process potentially killed → user returns → new process, new ViewModel, but SavedStateHandle restores all five fields from the Bundle.
Question 2 — Senior:
"What's the size limit for
onSaveInstanceStateandSavedStateHandle, and what happens if you exceed it?"
Both are backed by the same Bundle mechanism and share the same IPC transaction buffer (~1MB total across all IPC calls in flight). The practical per-Bundle limit is generally considered to be around 500KB to be safe.
Exceeding it throws TransactionTooLargeException — often silently in older API levels, crashing in newer ones.
The fix: never store lists, bitmaps, or full domain objects. Store IDs and let the repository reconstruct the data. A single String orderId is 50 bytes. A list of 500 Order objects could be megabytes.
// ❌ Can exceed Bundle limit
savedStateHandle["search_results"] = listOf<Product>(/* 500 items */)
// ✅ Store the query, re-fetch the results
savedStateHandle["search_query"] = "headphones"
// Results re-fetched from Room cache using the query on restoration
Question 3 — Senior / Architecture:
"How does the Navigation Component interact with
SavedStateHandle? How do you pass a result back from a destination screen?"
Navigation Component automatically populates SavedStateHandle with navigation arguments — no manual putExtra needed.
// Nav graph:
// <argument android:name="orderId" app:argType="string" />
// In ViewModel — orderId automatically available via SavedStateHandle
val orderId: String = savedStateHandle["orderId"]!!
For passing results back to the previous destination — the modern replacement for onActivityResult:
// In the destination that produces a result (EditFragment):
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.saveButton.setOnClickListener {
// Set result on the PREVIOUS destination's SavedStateHandle
navController.previousBackStackEntry
?.savedStateHandle
?.set("address_updated", true)
navController.popBackStack()
}
}
// In the source fragment that consumes the result (ProfileFragment):
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
navController.currentBackStackEntry
?.savedStateHandle
?.getLiveData<Boolean>("address_updated")
?.observe(viewLifecycleOwner) { updated ->
if (updated) viewModel.refreshProfile()
}
}
This replaces startActivityForResult / onActivityResult with a type-safe, lifecycle-aware mechanism that works correctly with the back stack.
What surprised me revisiting this
The Bundle size limit is shared across IPC calls, not just your Activity. Under heavy IPC load (system services, cross-process communication), the effective limit is lower than 1MB.
TransactionTooLargeExceptioncan appear to come out of nowhere because it's not always caused by your Bundle alone.View auto-save only works with an XML
id— I knew this intellectually but had never thought carefully about why. Views without IDs silently lose state. Generating IDs programmatically (e.g.,View.generateViewId()) doesn't help — the ID changes across restarts, so the saved state can't be matched.savedStateHandle.getStateFlow()makesSavedStateHandlegenuinely pleasant to use in a reactive codebase. I'd been using the getter/setter API and treating it as a key-value store. Treating it as aFlowsource is cleaner and composes well withflatMapLatestfor derived state.
Tomorrow
Day 10 → Main Thread Model: Looper, Handler, MessageQueue — the machinery behind Android's threading model, and why understanding it matters even in a world of Coroutines.
Which of the three mechanisms do you reach for first when you need to preserve state? Has your answer changed over the years?
← Day 8: Process Death — What Actually Happens When Android Kills Your App
Top comments (0)