The ViewModel Performance Trap: When State Management Bites Back

SinanKOZAK 46 views 54 slides Oct 22, 2025
Slide 1
Slide 1 of 54
Slide 1
1
Slide 2
2
Slide 3
3
Slide 4
4
Slide 5
5
Slide 6
6
Slide 7
7
Slide 8
8
Slide 9
9
Slide 10
10
Slide 11
11
Slide 12
12
Slide 13
13
Slide 14
14
Slide 15
15
Slide 16
16
Slide 17
17
Slide 18
18
Slide 19
19
Slide 20
20
Slide 21
21
Slide 22
22
Slide 23
23
Slide 24
24
Slide 25
25
Slide 26
26
Slide 27
27
Slide 28
28
Slide 29
29
Slide 30
30
Slide 31
31
Slide 32
32
Slide 33
33
Slide 34
34
Slide 35
35
Slide 36
36
Slide 37
37
Slide 38
38
Slide 39
39
Slide 40
40
Slide 41
41
Slide 42
42
Slide 43
43
Slide 44
44
Slide 45
45
Slide 46
46
Slide 47
47
Slide 48
48
Slide 49
49
Slide 50
50
Slide 51
51
Slide 52
52
Slide 53
53
Slide 54
54

About This Presentation

ViewModels are a lifesaver—until they’re not. In growing codebases, they can become a subtle source of latency and instability. Logic inside a ViewModel can kick off too eagerly, drift across threads, or execute far too late—making it a hidden bottleneck in your app’s startup and rendering p...


Slide Content

Sinan Kozak
The ViewModel
Performance Trap

Sinan
Kozak
Staff Android Developer
Google Developer Expert
“I develop Android
for 13 years, I am still
not bored thanks to
performance issues”

3
Sinan Kozak
Google Developer Expert &
Staff Android Developer
13 years Android experience

4
We deliver
food over
70 countries
with a single rider application

The ViewModel in
the title is a
clickbait.
Similar performance
issues can be found
everywhere.

Green is good

Red is bad

What is
ViewModel

ViewModel
The ViewModel class is a business logic or screen level state holder.
It exposes state to the UI and encapsulates related business logic.
Its principal advantage is that it caches state and persists it through
configuration changes. This means that your UI doesn’t have to
fetch data again when navigating between activities, or following
configuration changes, such as when rotating the screen.

https://developer.android.com/topic/libraries/architecture/viewmodel

data class DiceUiState(
val firstDieValue: Int? = null,
val secondDieValue: Int? = null,
val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

// Expose screen UI state
private val _uiState = MutableStateFlow(DiceUiState())
val uiState: StateFlow<DiceUiState> = _uiState. asStateFlow()

// Handle business logic
fun rollDice() {
_uiState.update { currentState ->
currentState. copy(
firstDieValue = Random. nextInt(from = 1, until = 7),
secondDieValue = Random. nextInt(from = 1, until = 7),
numberOfRolls = currentState. numberOfRolls + 1,
)
}
}
}

ViewModels are
simple, right?

class FeatureViewModel @Inject constructor(
private val feedProvider: FeedProvider,
private val uiComponentMapper: UiComponentMapper,
private val feedPaginationSource: FeedPaginationSource,
private val featureDataContainer: FeatureDataContainer,
private val navigationBarFactory: NavigationBarFactory,
private val searchUseCase: SearchUseCase,
private val locationProvider: LocationProvider,
private val performanceTracker: PerformanceTracker,
private val featurePerformanceTracker : FeaturePerformanceTracker,
private val contentStateManager: ContentStateManager,
private val favoritesInteractor: FavoritesInteractor,
private val featureConfig: FeatureConfig,
private val analyticsTrackerFactory : AnalyticsTrackerFactory,
private val filtersInteractor: FiltersInteractor,
private val featureFiltersUseCase : FeatureFiltersUseCase,
private val authEventBus: AuthEventBus,
private val contentStartParamsProvider : ContentStartParamsProvider,
private val impressionProvider: ImpressionProvider,
private val adsUseCase: AdsUseCase,
private val uiAdComponentMapper: UiAdComponentMapper,
private val actionViewHelper: ActionViewHelper,
private val rewardsInteractor: RewardsInteractor,
private val migrationStatusProvider : MigrationStatusProvider,
private val eventManager: EventManager,
private val experimentDataStore: ExperimentDataStore,
private val categoryManager: CategoryManager,
private val specialOfferComponent : SpecialOfferComponent,
private val subscriptionApi: SubscriptionApi,
private val specialOfferManager: SpecialOfferManager,
…,
) : ViewModel(), FiltersInteractor by filtersInteractor {

}

Basic suggestions
does not scale
Every new feature accumulates the tech dept.
We don’t have best practice how to solve
increased complexity.

What if your
ViewModel is
slow to create?

class FeatureViewModel @Inject constructor(
private val feedProvider: FeedProvider,
private val uiComponentMapper: UiComponentMapper,
private val feedPaginationSource: FeedPaginationSource,
private val featureDataContainer: FeatureDataContainer,
private val navigationBarFactory: NavigationBarFactory,
private val searchUseCase: SearchUseCase,
private val locationProvider: LocationProvider,
private val performanceTracker: PerformanceTracker,
private val featurePerformanceTracker : FeaturePerformanceTracker,
private val contentStateManager: ContentStateManager,
private val favoritesInteractor: FavoritesInteractor,
private val featureConfig: FeatureConfig,
private val analyticsTrackerFactory : AnalyticsTrackerFactory,
private val filtersInteractor: FiltersInteractor,
private val featureFiltersUseCase : FeatureFiltersUseCase,
private val authEventBus: AuthEventBus,
private val contentStartParamsProvider : ContentStartParamsProvider,
private val impressionProvider: ImpressionProvider,
private val adsUseCase: AdsUseCase,
private val uiAdComponentMapper: UiAdComponentMapper,
private val actionViewHelper: ActionViewHelper,
private val rewardsInteractor: RewardsInteractor,
private val migrationStatusProvider : MigrationStatusProvider,
private val eventManager: EventManager,
private val experimentDataStore: ExperimentDataStore,
private val categoryManager: CategoryManager,
private val specialOfferComponent : SpecialOfferComponent,
private val subscriptionApi: SubscriptionApi,
private val specialOfferManager: SpecialOfferManager,
…,
) : ViewModel(), FiltersInteractor by filtersInteractor {

}

val homeViewModel =
lifecycleScope.async(Dispatchers.IO) {
viewModels<HomeViewModel>().value
}
homeViewModel.await()

You could end up
having multiple
VM instance

Multiple instance problem
When multiple view model requested
before finishing the first one,
ViewModelProvider will start creating new
ones. The ViewModelProvider is not thread
safe.
Suspend ViewModel
creation
We would like to be do other operations
until ViewModel ready, the suspend api
requires boilerplate code and error prone
if someone uses runBlocking.

Parallel VM injection?

Use Lazy
injection to
sweep it under
the rug

class HomeViewModel @Inject constructor(
private val feed: Lazy<HomeFeed>,
private val componentMapper: Lazy<HomeUiComponentMapper>,
private val homeFeedTokenPageSource : Lazy<HomeFeedTokenPageSource>,
private val homeDataContainer: Lazy<HomeDataContainer>,
private val navBarFactory: Lazy<HomeNavBar.UiModel.Factory>,
private val searchBarUseCase: Lazy<SearchBarUseCase>,
private val addressProvider: Lazy<AddressProvider>,
private val performanceTracker: Lazy<PerformanceTrackingManager>,
private val homePerformanceTracker : Lazy<HomePerformanceTracker>,

Use Lazy for
injection?

Find the root
cause of
slowness…

fun <K, T> K.letAndMethodTrace(name: String, block: ( K) /> T): T {
val bufferSize = 100 * 1024 * 1024
Debug.startMethodTracing(name, bufferSize)

return block(this).also {
Debug.startMethodTracing()
}
}

// Fetch trace file from device and inspect in Perfetto or AndroidStudio

// onCreate - before any usage
val homeViewModel = viewModels<HomeViewModel>()
.letAndMethodTrace("HomeViewModel ${System.currentTimeMillis() }") {
it.value
}

Investigate big
chunks in
Main Thread

Don’t hesitate
Dig deeper

Single usage
We can make the parsers logic lazy
inside so we delay the init cost until it is
actually used.
Multiple usage
Consider caching the instance and
reuse. After caching, consider warming
up the expensive operation in
background thread before it is actually
needed

Lazy or Cache or Warm up

Using init block

class FooViewModel(
val repository: Repository,
) : ViewModel() {

val state: MutableStateFlow<String?> = …

init {
viewModelScope.launch {
val data = repository.getData()
state.value = data
}
}
}

getData
Value assignment
Main Thread operations
init can delay
ViewModel creation
Field assignments
init block
onCreate
scope.launch

class FooViewModel(
val repository: Repository,
) : ViewModel() {

val state: MutableStateFlow<String?> = …

init {
viewModelScope.launch {
val data = repository.fetchData()
state.value = data
}
}
}

Value assignment
Wait until
main thread
idle
“onStart,
onResume”
fetchData uses
IO internally
Lost time
ViewModel creation
Field assignments
init block
onCreate
scope.launch
First frame

class FooViewModel(
val repository: Repository,
) : ViewModel() {

val state: MutableStateFlow<String?> = …

init {
viewModelScope.launch(Dispatchers.IO) {
val data = repository.fetchData()
state.value = data
}
}
}

ViewModel creation
Field assignments
init block
onCreate
scope.launch
fetchData uses
IO internally
Value assigned
in IO without
waiting main
Main Thread
operations
Gain
First frame

viewModelScope
and lifecycleScope
use main.immediate

Dispatchers.main
It is single main thread. There is event
queue to run one by one. All coroutines
in main post a runnable to message
queue Dispatchers.main.immediate
Mostly same as main, but runs the
execution immediately on same thread if it
is already on the Main Thread.

main vs main.immediate

The default constructor of the ViewModel create a coroutine scope.
How to change
viewModelScope
CloseableCoroutineScope( /
coroutineContext = Dispatchers.Main.immediate + SupervisorJob() /
) /
public actual constructor (viewModelScope: CoroutineScope) { /
impl = ViewModelImpl(viewModelScope) /
} /
By default ViewModel will use Main.immediate.
Second constructor of ViewModel accepts CoroutineScope.

class FooViewModel(
val repository: Repository,
val coroutineScope: CoroutineScope,
) : ViewModel(coroutineScope) {

val state: MutableStateFlow<String?> = …

init {
viewModelScope.launch {
val data = repository.fetchData()
state.value = data
}
}
}

ViewModel creation
Field assignments
init block
onCreate
scope.launch
fetchData uses
IO internally
Value assigned
in IO without
waiting main
Main Thread
operations
Gain
First frame

@Provides
@ForScope(ViewModelScope/:class)
fun viewModelScope(): CoroutineScope = CoroutineScope(
SupervisorJob() +
Dispatchers.IO +
CoroutineName( "CustomViewModelScope" ) +
CoroutineExceptionHandler { _, exception ->
Timber.e(exception, "Exception in ViewModelScope" )
}
)

When is good
time to load the
data?

.stateIn
There are WhileSubscribed,
Eager and Lazily options to
create a state flow.
Depending on our need of
cache and timing of the
operation, we can fine tune.
Cold flow
Start long operation in a cold
flow. Use onStart function of
flow to set the default state.
This will load the data on
demand with collection.
No need to call the VM in
lifecycle functions

During Navigation
Do not wait for ViewModel to
created/used, start your logic
in a repository during the
navigation early as possible.
The ViewModel can observe
the result.
Minimum dependency on
ViewModel and Lifecycle.

When to load the data?

https://proandroiddev.com/loading-initial-data-in-launchedeffect-vs-viewmodel-f1747c20ce62
https://proandroiddev.com/loading-initial-data-part-2-clear-all-your-doubts-0f621bfd06a0
val state: Flow<String> = flow {
emit(repository.fetchData())
}.onStart {
emit("Default value")
}
val state: StateFlow<String> = flow {
emit(repository.fetchData())
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = "Default value"
)

Better change
dispatcher to
be safe?

Dispatcher change?
When is better to do context change?

Only when it is necessary
at lower levels

Run in parallel
and keep the
control

Your features
just need a
state holder

ViewModel's core role is configuration change persistence. Complex
state/data logic can be delegated to a UiModel (or other Data
Holder/UseCase/Repository) that does not inherit from ViewModel. A
single VM can manage the lifecycle and delegate UiModel creation or
heavy initialization to a background thread safely.

ViewModel is a
Holder Pattern

class UiModels @Inject constructor(
private val homeUiModelFactory: HomeUiModel.Factory,
coroutineScope: CoroutineScope, // Dispatchers.IO
) : ViewModel(coroutineScope) {

val homeUiModel: StateFlow<HomeUiModel?> =
flow {
emit(value = homeUiModelFactory.create())
}
.stateIn(
scope = viewModelScope,
// Or Lazily if you want cold or Eagerly
started = WhileSubscribed(
stopTimeoutMillis = 5000
),
initialValue = null
)
}


class Home {

@Composable
fun Content(uiModels: UiModels) {
val homeUiModel =
uiModels
.homeUiModel
.collectAsStateWithLifecycle (null)
}
}


class HomeUiModel @Inject constructor(
// other expensive injections
private val coroutineScope: CoroutineScope,
// Dispatchers.IO
): ViewModel(coroutineScope) {
// UI logic
interface Factory {
fun create(): HomeUiModel
}
}

https://www.droidcon.com/2023/08/01/composing-viewmodels-breaking-viewmodels-into-smaller-self-contained-ui-models/

Performance is not just
about executing logic async;
it's about removing
unnecessary waits and
unexpected behavior.

Asynchronous Work
Use the recommended lazy
observation pattern (onStart +
stateIn). Carefully manage
context switching
(withContext) and understand
the immediate vs. queued
nature of Main Dispatchers.
Injection Speed
Ensure dependencies are fast
and side-effect free in the
constructor/init phase. Avoid
starting long-running
requests in the init block.

Pay Attention Timing
It is important where your work
starts (init vs. observation),
where it runs (Main vs.
Default/IO), and whether it is
observed when the job is done.

Performance Checklist

Thank you
Do you have any questions?
github.com/kozaxinan
linkedin.com/in/sinankozak
strava.com/athletes/sinankozak
@snnkzk