DEV Community

Cover image for Create Now, Create on First Touch, Create When I Say So: What val, by lazy, and lateinit Really Do
Android Expert
Android Expert

Posted on Originally published at Medium

Create Now, Create on First Touch, Create When I Say So: What val, by lazy, and lateinit Really Do

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: Service creates nothing. You assign it, and the compiler trusts you until the first read.

three-lifecycles

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

Output:

Probe[eager] created
Holder constructed
-- constructed, nothing touched yet
Probe[lazy] created
-- deferred read twice
Probe[late] created
-- manual assigned
Enter fullscreen mode Exit fullscreen mode

Three observations fall out of it:

  1. The eager probe exists before Holder finishes constructing.
  2. The lazy probe was built exactly once, even though we read the property twice, and both reads returned the same instance.
  3. The lateinit probe 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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

lazy-first-vs-later-access

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

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

lazy-thread-safety-modes

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

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

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

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.

lateinit-state-machine

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 Int or Boolean. For those, use Delegates.notNull<T>() or a nullable type.
  • Reading it before assignment throws UninitializedPropertyAccessException.
  • Inside the same scope that can see the property, ::service.isInitialized lets 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
Enter fullscreen mode Exit fullscreen mode

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

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

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.

decision-guide

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.


Originally published on Medium.

Top comments (0)