Hilt Scoping Mistakes That Leak a Whole Screen
Hilt scoping bugs compile cleanly, pass tests, and quietly leak memory. Four scoping mistakes I keep finding in production Android codebases — and the one rule that prevents all of them.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — A Hilt scope is a statement about lifetime, not about cost. Most scoping bugs come from
choosing a scope to avoid re-creating an object, rather than to express what must die with what. The
four that leak most often: application-scoped objects holding an Activity Context, @Singleton
caches that survive logout, @ActivityRetainedScoped used as a shared ViewModel, and reflexively
adding @Singleton to stateless objects.
Why scoping bugs survive review
A wrong scope produces no compiler error, no runtime exception, and no failing test. The feature works. The only symptom is that memory doesn't come back down — and nobody looks at a heap dump until something else forces them to.
That makes scoping one of the few Android topics where reading the code carefully genuinely beats testing. So it's worth knowing exactly what each scope binds your object's lifetime to.
Mistake 1: an application-scoped object holding an Activity Context
// Leak: this instance lives for the process, and now so does the Activity.
@Singleton
class ThemeManager @Inject constructor(private val context: Context)
Whatever Hilt hands you here depends on where the binding came from, and that ambiguity is the whole
problem. A @Singleton outlives every Activity, so if an Activity Context reaches it, that entire
Activity — with its view hierarchy — cannot be collected.
@Singleton
class ThemeManager @Inject constructor(
@ApplicationContext private val context: Context,
)
Rule: an application-scoped object may only hold application-scoped things. The qualifier isn't ceremony; it's the type system carrying lifetime information it otherwise couldn't.
Mistake 2: a @Singleton cache that outlives the session
@Singleton
class UserProfileCache @Inject constructor() {
private val byId = mutableMapOf<UserId, Profile>()
}
This is correct-looking and wrong. The graph has no concept of "logged out". On logout you keep the previous user's data in memory — a privacy problem before it's ever a memory one — and the next user may read stale entries.
Two honest options:
- Bind session state to a component you actually destroy, and recreate that component on auth change.
- Keep the singleton, but give it an explicit
clear()that your logout path calls, and test that path.
The second is less elegant and far more common in real codebases. What matters is that the lifetime is stated somewhere rather than assumed.
Mistake 3: @ActivityRetainedScoped as "a ViewModel, but shared"
@ActivityRetainedScoped survives configuration changes — which makes it look like a ViewModel. The
difference that bites: it's shared by every ViewModel on that Activity.
// Two unrelated features now share mutable state.
@ActivityRetainedScoped
class FilterState @Inject constructor() {
var query: String = ""
}
In a single-Activity app, that's effectively global state with a rotation-friendly lifetime. The bug shows up as "the search filter from screen A is applied on screen B", and only when a user opens them in a particular order — which is why it reaches production.
Use it for genuinely Activity-wide concerns. For feature state, keep it in the feature's ViewModel and pass what's needed.
Mistake 4: reaching for @Singleton to avoid allocations
@Singleton
class UserMapper @Inject constructor() // stateless, ~0 cost to create
Unscoped is the correct default. An unscoped binding creates a new instance per injection point, and for a stateless mapper that is genuinely free. Scoping it buys nothing and costs you a permanent reference plus a lifetime you now have to reason about.
Scope for shared state, not for performance. If the object has no mutable state, there is nothing to share, so there is nothing to scope.
The rule that prevents all four
Before adding a scope annotation, answer one question:
What has to die with what?
- Must die with the Activity →
@ActivityScoped - Must survive rotation but die with the screen → the ViewModel
- Must live as long as the user's session → a session-shaped component, or an explicit
clear() - Must live for the process →
@Singleton, and it may only hold other process-lived things - Holds no mutable state → no scope at all
If your answer is "I just don't want to construct it twice", that's not a lifetime argument, and the annotation is wrong.
Verifying it
Scoping is one of the rare cases where a heap dump is faster than reasoning. Open the screen, leave it, force a GC, and check the instance count in Android Studio's Memory Profiler. An Activity that appears twice after two visits is a leak — and a scope annotation is the first place I look.