DEV Community

Cover image for Day 9/100: onSaveInstanceState vs ViewModel vs SavedStateHandle — Pick the Right Tool
Hoang Son
Hoang Son

Posted on

Day 9/100: onSaveInstanceState vs ViewModel vs SavedStateHandle — Pick the Right Tool

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:

  1. onSaveInstanceState — the original, been there since API 1
  2. ViewModel — survives configuration changes, introduced in 2017
  3. 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:

  • ViewModel for "real" data
  • onSaveInstanceState for UI state like scroll position
  • SavedStateHandle as a "better onSaveInstanceState inside 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
Enter fullscreen mode Exit fullscreen mode

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"))
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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 Parcelable or Serializable. Storing your domain model directly is fragile.
  • Logic — onSaveInstanceState lives 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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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("") }
Enter fullscreen mode Exit fullscreen mode

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 }
    )
}
Enter fullscreen mode Exit fullscreen mode

🧪 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.
Enter fullscreen mode Exit fullscreen mode

❓ 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", "")
}
Enter fullscreen mode Exit fullscreen mode

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 onSaveInstanceState and SavedStateHandle, 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
Enter fullscreen mode Exit fullscreen mode

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"]!!
Enter fullscreen mode Exit fullscreen mode

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()
        }
}
Enter fullscreen mode Exit fullscreen mode

This replaces startActivityForResult / onActivityResult with a type-safe, lifecycle-aware mechanism that works correctly with the back stack.


What surprised me revisiting this

  1. 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. TransactionTooLargeException can appear to come out of nowhere because it's not always caused by your Bundle alone.

  2. 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.

  3. savedStateHandle.getStateFlow() makes SavedStateHandle genuinely 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 a Flow source is cleaner and composes well with flatMapLatest for 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)