← Back to list

Part 5: The Object That Refuses to Die: Memory Ceilings and Assisted Initialization at Scale

How Paging, Assisted Injection, and Clean UseCases Save Android Apps From OOM Crashes and Broken DI Graphs

Android Expert · 2026-07-30 03:57 · 4 claps · 13.5 min read paywalled
#android-development #viewmodel-architecture #dependency-injection #android-hilt #clean-architecture
Open on Medium ↗
Wiki topics: GEN · Genomics & Sequencing 📱 · Mobile Development 🏛️ · Architecture

Part 5: The Object That Refuses to Die: Memory Ceilings and Assisted Initialization at Scale

The Object That Refuses to Die: Memory Ceilings and Assisted Initialization at Scale

The Object That Refuses to Die: Memory Ceilings and Assisted Initialization at Scale

Not a Medium Member? “Read For Free”

👉 Part 4: Cold Flows, Hot Bugs: Stabilizing Upstream Pipelines Against Compose Recomposition

You’ve fixed the serialization bugs, killed the blocking-thread leaks, and tuned your lifecycle grace windows to the millisecond. It feels like a finished architecture — until scale introduces two gatekeepers that don’t negotiate: the OS memory manager and the DI graph compiler. One kills your process; the other refuses to build.

Both failures share a root cause: treating the ViewModel as a dumping ground instead of an orchestrator. This piece draws that line back in, with a memory model and a construction model that hold up under real production load.

Mental Model #1: A ViewModel Isn’t “Cleaned Up” — It’s “Kept Alive”

Most of this pain starts with one misconception: because a ViewModel eventually gets destroyed, whatever it holds in the meantime feels safe. Flip that assumption. Surviving configuration changes and short navigation trips isn't a cleanup guarantee — it's a retention guarantee. Anything you put in a ViewModel stays resident for as long as the screen, or its back-stack entry, is alive.

That distinction bites hardest with data. Take an analytics dashboard that eagerly loads everything up front to avoid refetching on minor UI updates:

// ANTI-PATTERN: an unbounded fetch with no compile-time ceiling
class AnalyticsViewModel(
    private val analyticsRepository: AnalyticsRepository
) : ViewModel() {

    private val _chartData = MutableStateFlow<List<DataPoint>>(emptyList())
    val chartData: StateFlow<List<DataPoint>> = _chartData.asStateFlow()

    fun loadCompleteHistoricalData(userId: String) {
        viewModelScope.launch {
            val massiveList = analyticsRepository.fetchFullHistory(userId)
            _chartData.value = massiveList // fully materialized in RAM
        }
    }
}

This is invisible in a sandbox — nobody QA-testing a demo scrolls through six months of telemetry or bounces across a dozen historical views in one session. In production, a power user with 20,000+ entries does exactly that, and the failure unfolds in three predictable acts: the repository expands the full history into one contiguous list; the user taps into a child screen and the ViewModel — and all 20,000 objects with it — survives the trip; filters get toggled, derived arrays pile up alongside the original, and the resulting GC pressure cascades into an OutOfMemoryError. Nothing here was a bug in the traditional sense — every line did what it was written to do. The failure was architectural: no ceiling was ever defined.

The Fix: Stop Loading Data, Start Streaming Windows

The fix isn’t “load less data” — it’s changing the data structure’s contract from the whole collection, guaranteed to the visible window, guaranteed. That’s what Android’s Paging library gives you once it’s wired through cachedIn:

paging pipeline

paging pipeline

data class DataPoint(val id: String, val timestamp: Long, val value: Double)

class MemorySafeAnalyticsViewModel(
    private val analyticsRepository: AnalyticsRepository
) : ViewModel() {

    // A memory ceiling that holds regardless of dataset size
    val safeChartDataStream: Flow<PagingData<DataPoint>> = Pager(
        config = PagingConfig(
            pageSize = 50,
            prefetchDistance = 15,
            enablePlaceholders = false
        ),
        pagingSourceFactory = { analyticsRepository.getAnalyticsPagingSource() }
    ).flow.cachedIn(viewModelScope)
}

interface AnalyticsRepository {
    fun getAnalyticsPagingSource(): PagingSource<Int, DataPoint>
}

cachedIn(viewModelScope) isn't optional. Without it, the Paging stream has no anchor — every recomposition or fresh collection tears down the existing PagingSource and rebuilds it, so a rotation or navigation event silently re-triggers a network or database call you never asked for. It's what turns "paging" into "paging that actually caches."

That wiring is table stakes, though. The failures that actually show up in production sit one layer deeper:

  • Invalidation storms. Calling PagingSource.invalidate() from inside a Flow.combine with a fast-changing upstream — a search-query StateFlow, say — can retrigger a full reload on every keystroke. Debounce the upstream before it reaches the Pager, not after.
  • **map on PagingData breaks identity.** Transforming items downstream of cachedIn creates new object instances on every page load, defeating DiffUtil's ability to tell "same item, re-fetched" from "new item." Do heavy mapping inside the PagingSource itself, before the cache boundary, so identity survives into the UI diff.
  • **RemoteMediator complicates the ceiling.** Add one for network-plus-database paging and you've introduced a second store — the local DB — that PagingConfig doesn't bound. The visible list stays small; the underlying table can still grow unbounded without a prefetch-and-evict policy at the database layer.
  • **initialLoadSize vs. pageSize mismatches.** Leaving initialLoadSize at its default (3× pageSize) on a screen that's already expensive to compose spikes network payload and initial recomposition cost simultaneously. Set it explicitly to match your actual above-the-fold item count.
  • Placeholders lie about scroll position. enablePlaceholders = true gives an accurate scrollbar, but only if your source reports a reliable total count. Get that count wrong — common under server-side filtering — and users see phantom blank rows. Default to false unless count accuracy is verified end to end.
  • Test the diffing, not just the fetch. AsyncPagingDataDiffer with a TestListCallback catches item-identity regressions that a naive "does the list have N items" test won't — it's exactly where the map-breaks-identity bug above gets caught before shipping.

Mental Model #2: Constructors Assume One Source of Truth — Real Apps Have Three

The second gatekeeper shows up earlier, at construction time. Standard DI frameworks assume every constructor parameter resolves from one graph, known entirely at compile time. That assumption breaks the moment a ViewModel needs to blend three genuinely different sources of truth:

three sources of truth

three sources of truth

Standard @HiltViewModel constructor injection only speaks the first language fluently. Force it to accept the other two and you either bloat the constructor with mutable containers or start fighting the graph outright. Assisted injection is the adapter between these three worlds — it lets DI resolve what it can, and explicitly hands off the rest:

class ComplexInitializationViewModel @AssistedInject constructor(
    // resolved by the DI graph
    private val billingRepository: BillingRepository,
    // provided by the framework at runtime
    @Assisted private val savedStateHandle: SavedStateHandle,
    // supplied explicitly by the caller
    @Assisted("accountId") private val accountId: String
) : ViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(
            savedStateHandle: SavedStateHandle,
            @Assisted("accountId") accountId: String
        ): ComplexInitializationViewModel
    }

    companion object {
        fun provideFactory(
            assistedFactory: Factory,
            accountId: String
        ): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
            @Suppress("UNCHECKED_CAST")
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                return assistedFactory.create(
                    savedStateHandle = SavedStateHandle(),
                    accountId = accountId
                ) as T
            }
        }
    }
}

interface BillingRepository

On the Compose side, the factory resolves cleanly against the nearest composition scope, with no manual lifecycle wiring required:

@Composable
fun BillingScreen(
    accountId: String,
    assistedFactory: ComplexInitializationViewModel.Factory
) {
    val viewModel: ComplexInitializationViewModel = viewModel(
        factory = ComplexInitializationViewModel.provideFactory(assistedFactory, accountId)
    )
    // UI layout binding logic...
}

Because SavedStateHandle still flows in through the platform's own factory machinery, process-death restoration keeps working exactly as it did with plain constructor injection — assisted injection changes how dependencies arrive, not whether state survives. Where it does bite experienced teams is downstream of that wiring:

  • Unstable factory calls create silent duplicate work. Calling provideFactory(...) inline inside a @Composable, without wrapping it in remember, allocates a new ViewModelProvider.Factory on every recomposition. It won't necessarily create a new ViewModel instance — the provider still keys off the ViewModelStore — but the factory closure gets rebuilt constantly, showing up as confusing profiler noise. Wrap it: val factory = remember(accountId) { ComplexInitializationViewModel.provideFactory(assistedFactory, accountId) }.
  • Scope mismatches produce genuinely duplicate instances. Request the same assisted ViewModel from two different NavBackStackEntry scopes — a screen-level scope and a parent nav-graph scope — with different accountId arguments, and you get two live instances silently diverging in state. Code review rarely catches this; it shows up as "the UI is out of sync with itself."
  • **@Assisted parameters aren't a substitute for SavedStateHandle persistence.** A caller-supplied argument is only as durable as the caller. On process death, it's SavedStateHandle that survives, not the assisted parameter — if accountId needs to outlive process death, write it into the handle explicitly on first construction.
  • Multiple @AssistedFactory interfaces on one Hilt component produce cryptic graph errors that almost always trace back to two assisted parameters of the same type missing distinguishing @Assisted("name") qualifiers. The compiler error rarely points at the actual gap, so check that first.
  • Testing gets harder, not easier. Assisted-injected ViewModels can't be trivially constructed in a JVM unit test via a generated Hilt component the way a plain @HiltViewModel can. Most teams hand-invoke the constructor directly and skip the @AssistedFactory indirection in unit tests — which is fine, as long as something else, usually instrumented or Compose UI tests, actually exercises the factory wiring itself.

Mental Model #3: A ViewModel’s Job Is Translation, Not Decision-Making

There’s a slower, quieter failure mode than OOM crashes or DI errors: a clean 50-line ViewModel gradually absorbing every nearby concern until it's 900 lines of networking, validation regex, threading, and error parsing. The fix here is a discipline, not a library — a ViewModel translates, it never decides. It maps domain data into UI state and UI events into business commands. The moment it starts encoding a business rule — a validation pattern, a pricing calculation, a retry policy — that logic has outgrown the ViewModel and belongs one layer down:

viewmodel responsibility chain

viewmodel responsibility chain

A single-responsibility UseCase makes that boundary concrete:

class ValidateTaxIdUseCase {
    private val taxIdPattern = Regex("^\\d{3}-\\d{2}-\\d{4}$")

    suspend operator fun invoke(rawTaxId: String): ValidationResult =
        withContext(Dispatchers.Default) {
            val sanitized = rawTaxId.trim()
            when {
                sanitized.isEmpty() -> ValidationResult.Invalid("Field cannot be empty")
                !sanitized.matches(taxIdPattern) -> ValidationResult.Invalid("Malformed SSN syntax")
                else -> ValidationResult.Valid(sanitized)
            }
        }
}

sealed interface ValidationResult {
    data class Valid(val sanitizedValue: String) : ValidationResult
    data class Invalid(val technicalReason: String) : ValidationResult
}

With the rule extracted, the ViewModel shrinks back to what it should have been all along — a thin coordination layer:

class StreamlinedRegistrationViewModel(
    private val validateTaxIdUseCase: ValidateTaxIdUseCase,
    private val registrationRepository: RegistrationRepository
) : ViewModel() {

    private val _errorState = MutableStateFlow<String?>(null)
    val errorState = _errorState.asStateFlow()

    fun handleTaxIdSubmission(input: String) {
        viewModelScope.launch {
            when (val result = validateTaxIdUseCase(input)) {
                is ValidationResult.Invalid -> _errorState.update { result.technicalReason }
                is ValidationResult.Valid -> {
                    _errorState.update { null }
                    registrationRepository.saveTaxId(result.sanitizedValue)
                }
            }
        }
    }
}

interface RegistrationRepository {
    suspend fun saveTaxId(value: String)
}

Is this overkill for a class that just forwards a string to a repository? On day one, maybe — but the payoff isn’t about day one. Add offline sync, a new validation rule, or a caching policy later, and you touch one isolated UseCase, not a ViewModel wired into UI tests, navigation, and state restoration all at once. The extraction has its own sharp edges, though:

  • **viewModelScope.launch inside a fire-and-forget call still ties cancellation to the screen.** If registrationRepository.saveTaxId(...) needs to complete even after the user navigates away — an analytics write, a payment confirmation — don't launch it from viewModelScope, since it dies with the ViewModel. Use a longer-lived scope instead: an ApplicationScope injected into the repository, or WorkManager for anything that must survive process death.
  • Dispatcher choice inside the UseCase, not the caller, determines main-thread safety. The withContext(Dispatchers.Default) hop in ValidateTaxIdUseCase is what makes it safe to call from anywhere. Drop that hop, and the moment a teammate adds a heavier regex or a JSON parse to the "simple" validator, it silently starts blocking the main thread — with nothing at the call site to warn you.
  • One-method UseCase classes multiply DI graph nodes fast. Invisible at a few dozen; past a few hundred, it measurably slows Hilt/Dagger annotation processing and Gradle configuration time. Teams that hit this usually group closely related UseCases behind a single facade interface, trimming node count without losing the testability boundary.
  • A UseCase’s sealed result type is easy to get partially right. ValidationResult.Invalid("technicalReason") is fine for logs, but if that string leaks directly into UI copy, the domain layer is now coupled to presentation strings — the exact coupling this pattern exists to prevent. Keep the technical reason for telemetry; map it to a UI-facing string resource in the ViewModel, not the UseCase.

Performance Considerations That Don’t Show Up in Code Review

Everything above prevents correctness failures. These pass review clean and still degrade the app under real load:

  • **StateFlow conflation can hide dropped intermediate states.* It only guarantees the latest* value reaches a slow collector — if the UI briefly shows loading, then error, then success within one recomposition pass, a slow collector may never observe the error. Where intermediate states matter — analytics, one-shot error toasts — reach for SharedFlow or a Channel instead.
  • Recomposition scope is a memory-adjacent problem. A ViewModel that correctly avoids unbounded data can still trigger unbounded recomposition if it exposes one large data class for the whole screen instead of narrowly scoped StateFlows per section. Every field change recomposes every composable reading that class unless @Stable/@Immutable are applied correctly — check the Compose Compiler's stability report rather than assuming.
  • Profile with Macrobenchmark, not just the Memory Profiler. The Memory Profiler shows what’s resident, not that a cachedIn Paging stream is adding cold-start jank because the first page load blocks first-frame render. A startup or jank Macrobenchmark test catches that class of regression before it ships — it's the tool most teams skip until the complaint tickets arrive.
  • Baseline Profiles pay off after the architecture is fixed, not before. Paging, assisted injection, and UseCase extraction reduce steady-state memory and CPU pressure but do little for cold-start time. Once those fixes are in place, a Baseline Profile covering critical journeys — the dashboard load path, the billing screen construction path — is usually the next highest-leverage change, since AOT-compiling those paths removes JIT warm-up cost that architecture cleanup alone can’t touch.
  • GC pauses are a symptom, not the disease. Tuning largeHeap or manually calling System.gc() in response to OutOfMemoryError reports masks the underlying unbounded-collection problem rather than fixing it — largeHeap in particular makes the eventual crash worse by raising the ceiling the process falls from. Treat any GC-pause fix as a diagnostic pointing back at retention, not a solution on its own.

Run This Audit This Week

Three checks, in order of how fast they’ll surface a problem:

  1. Memory Profiler pass. Open a data-heavy screen, navigate the workflow repeatedly, and watch instance counts. A steadily climbing footprint of unpaged collections is your unbounded-list problem, live.
  2. The 300-line scan. Flag every ViewModel over 300 lines. Count how much of that length is string manipulation, error formatting, or validation logic that has no business being there.
  3. The construction check. Anywhere runtime arguments are getting threaded through mutable state containers to dodge a DI constructor, that’s a sign you need @AssistedInject, not a workaround.

None of these fixes are exotic — paging, assisted injection, and use-case extraction are all standard tools. What’s expensive isn’t applying them; it’s the debt that accumulates in the months before you do.

🙋 Frequently Asked Questions (FAQs)

Isn’t cachedIn(viewModelScope) enough on its own, or do I still need to worry about the repository layer?

cachedIn bounds what the UI holds, not what the data layer holds. A PagingSource sitting on a Room DAO with no eviction policy, or a RemoteMediator that keeps appending to a local table, keeps the on-screen list small while the underlying store keeps growing. Treat cachedIn as one half of the ceiling — the database or cache layer needs its own bound, whether that's a row-count cap, TTL-based eviction, or a refresh strategy that clears stale pages.

Does @AssistedInject replace @HiltViewModel, or do they coexist?

They coexist. Most codebases use @HiltViewModel for the common case — everything DI-resolvable — and reserve @AssistedInject for screens that genuinely need caller-supplied runtime arguments. Applying assisted injection "just in case" adds factory boilerplate for no benefit; use it only when a real runtime argument forces the issue.

How small is “too small” for a UseCase — should every repository call get wrapped in one?

No. A UseCase earns its place when it encodes a rule — validation, a calculation, a policy for combining multiple repository calls, error translation. One that does nothing but call repository.doThing() is pure indirection; some teams keep those as direct repository calls from the ViewModel and promote them only once a second concern appears. Extract when logic shows up, not up front.

Is StateFlow the wrong choice for all UI state, given the conflation issue?

No — for state representing "the current condition of the screen" (a persistent loading/success/error status, current list contents, current form values), StateFlow's latest-value-wins behavior is exactly right; a late collector should catch up to the newest value, not replay history. The conflation risk is specific to one-shot events — a snackbar, a navigation trigger, an analytics ping — where every emission matters. StateFlow for state, SharedFlow/Channel for events, and the confusion mostly disappears.

We’re not at 20,000-row scale yet — is it premature to apply all of this now?

Paging and use-case extraction are cheap to introduce early and expensive to retrofit later, since retrofitting means migrating live UI state shapes and touching every call site that reads them. Assisted injection is the one worth deferring — add it only when a genuine runtime argument forces your hand, since introducing it speculatively adds boilerplate nobody needs yet.

🙋 Questions From Readers

A few threads came up repeatedly after the last installment, worth settling here:

  • “Does SavedStateHandle restoration still work if the ViewModel is scoped to a nav graph instead of a single destination?" Yes, with one caveat: that SavedStateHandle is scoped to the nav graph's back-stack entry, not the individual screen. Two screens sharing a graph-scoped ViewModel are also sharing — and can overwrite — the same saved-state keys, so namespace keys per screen if that's a risk.
  • “Can I combine paging with pull-to-refresh without breaking the cache?” Yes — refresh() on the LazyPagingItems/adapter side exists for exactly this, and it respects the cachedIn boundary rather than fighting it. The mistake to avoid is manually re-invoking the Pager to "force a refresh," which is what causes duplicate-fetch bugs.
  • “What’s the actual overhead of an extra UseCase layer at runtime?” Functionally negligible — a UseCase is a plain class with usually one suspend function, with no reflection or proxying involved unless a DI decorator has been added. The cost teams actually feel is at build time, from graph node growth, not at runtime.

Got a question this piece didn’t answer? That’s what the comments are for — a real production edge case from your codebase is worth more to the next reader than anything hypothetical this post could invent.

The Real Takeaway

Every failure here — the OOM crash, the DI graph that won’t compile, the 900-line ViewModel — traces back to the same root habit: letting convenience at construction time become a liability at scale. None of it is exotic. It's the predictable result of skipping a boundary that felt optional on day one.

The fixes compose, though. A ViewModel that pages its data, accepts its dependencies explicitly, and delegates its business rules isn't just safer — it's smaller, easier to test, and easier for the next engineer to reason about without archaeology. That's the real ROI: not "fewer crashes" as an abstract metric, but a codebase that stays legible as it grows instead of degrading into the thing nobody wants to touch.

If any of this matched something you’re staring at right now — a ViewModel past 900 lines, a factory quietly creating duplicate instances, a StateFlow dropping states you didn't know it could drop — it's worth flagging to your team this week, not next quarter.

What’s the largest ViewModel you’ve had to untangle in production, and what finally forced the refactor? Drop it in the comments — the war stories are usually more instructive than the guide.

📱 Go Beyond Using Jetpack Compose

If you’re building on Android, understanding what happens under the hood separates developers who use Compose from those who master it. I highly recommend “Mastering Jetpack Compose Internals”. It’s a deep, architecture-first walkthrough of the composition tree, the slot table, snapshot state, and the runtime that powers modern Android UI — capped off with a full case study building a real app called Mosaic.


메타데이터
post_id
02fd8d31ab75
slug
part-5-the-object-that-refuses-to-die-memory-ceilings-and-assisted-initialization-at-scale-02fd8d31ab75
url
https://medium.com/@sivavishnu0705/part-5-the-object-that-refuses-to-die-memory-ceilings-and-assisted-initialization-at-scale-02fd8d31ab75
canonical_url
https://medium.com/@sivavishnu0705/part-5-the-object-that-refuses-to-die-memory-ceilings-and-assisted-initialization-at-scale-02fd8d31ab75
author_url
https://medium.com/@sivavishnu0705
status
ok
fetched_at
2026-08-19 03:19:33