The Android interview trap that sounds easy until the follow-up questions start.
Ask a room of Android developers how by lazy differs from a plain val, and most will say: "lazy means initialize later." That answer is true, and it is also the reason many candidates stall on the next question.
Interviewers rarely care whether you can recite the syntax. They want to know whether you understand when the object comes into existence, who decides that, and what breaks when your assumption is wrong. This post walks through the lifecycle of each form, then spends most of its time on the production failures that the one-line answer never mentions.
🧠 TL;DR (Too Long; Didn’t Read)
-
val x = Service()creates the object during construction. -
val x by lazy { Service() }creates it on first read, then caches it. -
lateinit var x: Servicecreates nothing. You assign it, and the compiler trusts you until the first read.
Watch the lifecycle instead of describing it
Before the theory, run this. Every probe prints when it is built, so the order tells the whole story.
class Probe(private val tag: String) {
init { println("Probe[$tag] created") }
}
class Holder {
val eager = Probe("eager")
val deferred by lazy { Probe("lazy") }
lateinit var manual: Probe
init { println("Holder constructed") }
}
fun main() {
val holder = Holder()
println("-- constructed, nothing touched yet")
check(holder.deferred === holder.deferred)
println("-- deferred read twice")
holder.manual = Probe("late")
println("-- manual assigned")
}
Output:
Probe[eager] created
Holder constructed
-- constructed, nothing touched yet
Probe[lazy] created
-- deferred read twice
Probe[late] created
-- manual assigned
Three observations fall out of it:
- The eager probe exists before
Holderfinishes constructing. - The lazy probe was built exactly once, even though we read the property twice, and both reads returned the same instance.
- The
lateinitprobe appeared only on the line where we wrote the assignment. Nothing in the language forced that moment.
1. val service = Service(): eager initialization
The initializer runs as part of construction. Property initializers and init blocks execute in textual order, top to bottom, interleaved with each other. That detail is where the eager form hides its own failure mode.
Failure mode: the init-order trap
class SessionTracker(private val clock: Clock) {
init { announce() } // runs first
private val startedAt = clock.instant() // runs second
private fun announce() {
println("Session started at $startedAt") // prints "null"
}
}
The compiler rejects a direct read of an uninitialized property, but it cannot see through a method call. announce() reads startedAt while the backing field still holds its default value, so a reference type reads as null even though its declared type is non-nullable. The same thing happens when a base-class constructor calls an open member that a subclass overrides: the override runs before the subclass properties exist.
The fix is boring: declare properties before the init blocks that depend on them, and keep constructors free of calls into overridable or order-sensitive members.
Reach for eager val by default. It is the simplest, the cheapest, and the easiest to reason about. Everything else on this page is a deliberate deviation from it.
2. val service by lazy { }: deferred and cached
lazy is not a keyword. It is an ordinary standard-library function that returns a Lazy<T>, and by hands the property's reads over to it. The compiler stores that Lazy in a hidden field and routes every read through it.
On the first read, the holder runs your lambda and stores the result. On every later read, it returns the stored value. The lambda is a one-shot factory, not a getter that re-executes. Once initialization succeeds, the holder releases its reference to the lambda, so whatever the lambda captured can be collected from that side.
A legitimate Android use
class CheckoutFragment : Fragment(R.layout.fragment_checkout) {
// Expensive to build, and many sessions never reach the code that uses it.
private val analytics by lazy(LazyThreadSafetyMode.NONE) {
AnalyticsClient.create(requireContext().applicationContext)
}
private fun onPayTapped() {
analytics.track("checkout_pay_tapped")
}
}
If the user leaves the screen without ever tapping pay, the client is never built. Two details matter here: the lambda captures the application context rather than the fragment, and the mode is NONE because this property is only ever touched from the main thread.
Is lazy thread-safe?
By default, yes. The default mode is SYNCHRONIZED, which guarantees that the initializer completes at most once and that every thread sees the same value. Kotlin lets you pick the behavior explicitly:
val a by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Service() }
val b by lazy(LazyThreadSafetyMode.PUBLICATION) { Service() }
val c by lazy(LazyThreadSafetyMode.NONE) { Service() }
The important sentence in that comparison is the one under PUBLICATION: several threads may run the initializer at the same time, and the first value published wins. The losing results are discarded. If your initializer opens a file, registers a listener, or starts a thread, you have just done that more than once.
Failure modes worth knowing by name
1. The cost did not disappear, it moved. Lazy initialization trades startup time for first-use time. If the first read happens on the main thread during a scroll or a transition, you have converted a startup cost into a dropped frame.
// Looks harmless. The first read happens inside onBindViewHolder.
private val secureStore by lazy { createKeystoreBackedStore(context) }
Keystore-backed setup is the kind of work that can be noticeably slow. If something is expensive and you know you will need it, warm it up deliberately on a background dispatcher instead of letting a random first read pay for it.
2. A lazy view binding outlives its view.
class ProfileFragment : Fragment(R.layout.fragment_profile) {
// Bug: cached for the life of the Fragment instance, not the life of its view.
private val binding by lazy { FragmentProfileBinding.bind(requireView()) }
}
A fragment’s view can be destroyed and recreated while the fragment instance lives on. The cached binding still points at the old hierarchy, which both leaks it and hands you a stale view after recreation. lazy caches forever, and a view's lifetime is not "forever". Tie the reference to the view lifecycle instead:
class ProfileFragment : Fragment(R.layout.fragment_profile) {
private var _binding: FragmentProfileBinding? = null
private val binding get() = checkNotNull(_binding) { "View is not available" }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
_binding = FragmentProfileBinding.bind(view)
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}
3. A throwing initializer runs again on the next read. If the lambda throws, nothing is stored. The next access tries again. That is usually what you want, but it also means a permanently failing initializer will re-run its expensive work and re-throw on every access, which can turn into log spam or repeated network calls.
4. Cycles between lazy properties. Two lazies that read each other deadlock under SYNCHRONIZED when different threads enter from opposite ends, because each thread holds one holder's lock while waiting for the other. On a single thread, the lock is reentrant but no value has been stored yet, so the initializer re-enters itself until you get a StackOverflowError. Both are design smells. Break the cycle instead of tuning the mode.
5. NONE on a property that quietly became multi-threaded. NONE has no safety net. It is correct for a property confined to the main thread and wrong the moment someone reads it from a coroutine on Dispatchers.Default. If you cannot guarantee confinement, pay for SYNCHRONIZED.
A cleaner alternative at the DI layer. When the real goal is “do not build this until needed” across a dependency graph, inject a dagger.Lazy<AnalyticsClient> or a Provider instead of hiding the decision inside each consumer. It keeps construction policy in one place and makes the consumer easy to test.
3. lateinit var service: manual initialization
lateinit is a promise to the compiler: "this will be assigned before anyone reads it, and I will not make you prove it." Nothing is created at declaration. The compiler generates a check in the getter that throws if the backing field is still null.
The rules
- It must be a
var. The property has to be assignable after construction. - The type must be non-null, and it cannot be a primitive type such as
IntorBoolean. For those, useDelegates.notNull<T>()or a nullable type. - Reading it before assignment throws
UninitializedPropertyAccessException. - Inside the same scope that can see the property,
::service.isInitializedlets you ask without triggering the exception.
Can lateinit be used with val?
No. A val has no setter and must be definitely assigned by the end of construction. lateinit exists precisely for the case where assignment happens later, so it needs a mutable property. If you want "deferred but immutable from the caller's side," that is by lazy: callers see a val with no setter and no way to reassign it.
If you want outside code to read but not write, narrow the setter:
lateinit var service: Service
private set
Failure modes worth knowing by name
1. Injection has not happened yet.
@AndroidEntryPoint
class FeedActivity : AppCompatActivity() {
@Inject lateinit var repository: FeedRepository
private val initialItems = repository.cachedItems() // crashes: not injected yet
}
Field injection runs during the activity’s creation lifecycle, after the property initializers have already executed. Anything that reads the injected field from a property initializer or init block fails. Read it from onCreate or later, or prefer constructor injection wherever the framework allows it.
2. Reading a singleton before Application.onCreate().
class App : Application() {
override fun onCreate() {
super.onCreate()
instance = this
}
companion object { lateinit var instance: App }
}
Content providers are instantiated before Application.onCreate() runs. A provider, or a library initializer that behaves like one, that reads App.instance will crash with a startup-time exception that only appears on cold start and is painful to reproduce. Treat the application object as unavailable until you know it has run.
3. Views after onDestroyView. A lateinit var holding a view or binding is never cleared automatically. It keeps pointing at a dead hierarchy after the view is destroyed. The nullable pattern shown earlier fixes this because it lets you explicitly drop the reference.
4. Visibility across threads. lateinit is not volatile and offers no synchronization. If one thread assigns and another reads, nothing guarantees the reader sees the write promptly. Use @Volatile for a cross-thread handoff, or choose a construct designed for it.
5. Using isInitialized as a crutch. Scattering if (::service.isInitialized) checks through a class usually means the ordering problem was never solved. If "not set yet" is a real, expected state, model it explicitly with a nullable type and handle it. Reserve lateinit for cases where being unset is a bug.
The mental model
Each form answers one question differently: who decides when the object exists?
-
val x = ...: the constructor decides, so it exists now. -
val x by lazy { ... }: the first reader decides, so it exists on first access. -
lateinit var x: you decide, so it exists when you assign it.
A quick way to choose:
-
You can build it now and it is cheap? Eager
val. -
You can build it now, but it is expensive or often unused?
by lazy, with a thread mode chosen on purpose. -
Someone else supplies it later, such as a framework callback or injection?
lateinit var, read only after that point. -
“Not set yet” is a legitimate state? A nullable
var, handled explicitly.
Follow-up questions, answered precisely
Is lazy thread-safe? By default, yes: SYNCHRONIZED guarantees one completed initialization and a consistent value across threads. PUBLICATION allows concurrent attempts and keeps the first published result. NONE has no protection and is only safe under thread confinement.
Can lateinit be used with val? No. It requires a mutable property. For deferred immutability, use by lazy.
Does lazy run its block every time? No. It runs until it succeeds once, then the result is cached. If the block throws, no value is stored, and the next access runs it again.
Which one is safest in a Fragment for a view reference? None of the three. A view reference needs to be cleared when the view is destroyed, so use a nullable backing field that is nulled in onDestroyView.
The answer interviewers are listening for
“Lazy means initialize later” is not wrong. It is a description of syntax, and syntax is the part you can look up.
The stronger answer describes a lifecycle: eager initialization is decided by construction, lazy initialization is decided by the first reader and then frozen, and lateinit is decided by you and enforced only at the moment of a read. Then it names what goes wrong at each boundary: the init-order trap, the main-thread first access, the stale view binding, the cold-start crash before Application.onCreate().
Don’t memorize the syntax. Understand the lifecycle.
🧵 Stop Treating Coroutines Like Magic Keywords
If “just add launch and hope” describes how your team handles concurrency, I’d recommend “Kotlin Coroutines Under the Hood” — a 25-chapter deep dive that goes past the syntax and down to the bytecode: the Continuation-Passing Style transform, the compiler-generated state machine, and exactly why a suspended coroutine costs a few dozen bytes while a blocked thread costs a megabyte.
It walks through structured concurrency and Job hierarchies, dispatcher internals (Default vs IO vs Unconfined), cooperative cancellation and exception aggregation, Flow’s cold-stream semantics and the full operator vocabulary, StateFlow/SharedFlow’s hot-broadcast model, Channels and fan-in/fan-out patterns, Mutex/Semaphore/actors for shared state, and a complete Android case study retrofitting a legacy callback-driven app onto coroutines and Flow — including a 100,000-coroutines-vs-100,000-threads benchmark lab you can run yourself.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.





Top comments (0)