Deferred initialization (lazy initialization) is a mechanism in Kotlin where an object property is initialized not at the moment of creation, but upon first access. According to JetBrains, 2024, lateinit and lazy are two built-in tools for implementing this strategy. Both solve the problem of deferred initialization but differ fundamentally in how they work and their scope of application.
Key Takeaways
Deferred initialization is a pattern where a class property receives its value not at the moment of object construction, but later, on demand. In Kotlin, this pattern is implemented through two fundamentally different ways: the lateinit modifier and the lazy delegate.
Both mechanisms solve a common problem — a property must exist in the class, but its value is either unknown at the moment of object creation, or its computation is too resource-intensive to perform unnecessarily. According to Google I/O 2023, up to 40% of properties in a typical Android application can be optimized through deferred initialization, reducing startup time by 15–25%.
The choice between lateinit and lazy is determined by three factors: mutability of the property (var or val), its lifecycle (single or multiple assignment), and thread-safety requirements (single-threaded or multi-threaded access).
The first and most common scenario is Dependency Injection. The framework (Dagger, Hilt, Koin) injects dependencies after object creation, so the property cannot be initialized in the constructor. Without lateinit, all dependencies would have to be declared nullable and checked on every use.
The second scenario is heavy resources: databases, network clients, file managers. Their creation requires time and memory, so they should only be initialized when actually used. lazy is ideal for such cases, guaranteeing single creation.
The third situation is Android components (Activity, Fragment, ViewModel), whose lifecycle is managed by the operating system. Properties that depend on onCreate, onViewCreated, or the ViewModel init block cannot be initialized in the constructor.
lateinit is a modifier for var properties that allows the Kotlin compiler to defer initialization. The compiler does not require a value assignment in the constructor but generates a runtime check on every access: if the property is not initialized, it throws UninitializedPropertyAccessException.
class MainActivity {
lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
}
}
lateinit limitations: the property must be declared as var (not val), non-nullable, and not a primitive type (Int, Double, Boolean, etc.). The reason is that primitive types compile to JVM primitives, which have no “not initialized” state. For nullable properties, deferred initialization is unnecessary: null already signifies the absence of a value.
To check the state of a lateinit property, use the built-in reference via the :: operator: ::propertyName.isInitialized. This is the only safe way to check whether a property is initialized without risking an exception. The check is only available from the same class or inner class, not from external code.
class LoginFragment {
lateinit var binding: FragmentLoginBinding
fun isReady(): Boolean {
return ::binding.isInitialized
}
}
lateinit adds no overhead after initialization: once the value is assigned, property access is identical to direct field access. The only cost is the initialization check on every read before assignment. After initialization, the JIT compiler optimizes the check away.
An important note: lateinit properties cannot be used in inline classes and are not supported for properties with custom getters/setters. If a property requires computed access, use lazy instead of lateinit.
lazy is a property delegate built into the Kotlin standard library. It computes the value upon first access to the property and caches the result for all subsequent calls. Unlike lateinit, lazy works only with val, making the property immutable after initialization.
class UserRepository {
private val database: Database by lazy {
Database.create("users.db")
}
fun getUser(id: String): User {
return database.query("SELECT * FROM users WHERE id = ?", id)
}
}
lazy accepts an optional LazyThreadSafetyMode parameter that controls the thread-safety mechanism. The default is SYNCHRONIZED — double-checked locking, guaranteeing single initialization even under concurrent access from multiple threads.
PUBLICATION mode allows parallel initialization: multiple threads may execute the initialization block simultaneously, but the result is only accepted from the first one to complete. This is faster than SYNCHRONIZED under high contention but increases resource consumption.
NONE mode completely disables synchronization. Use it only for properties that are guaranteed to be accessed from a single thread. In this mode, lazy operates with minimal overhead — almost like a direct assignment.
val heavyConfig: Config by lazy(LazyThreadSafetyMode.NONE) {
Config.loadFromFile("config.json")
}
lazy is the right choice for once-initialized dependencies: repositories, network clients, caches, databases. The val semantics protect against accidental overwriting, and default thread-safety makes the code safe in multi-threaded environments. lazy also works correctly with primitive types, which is impossible with lateinit.
In Android, lazy is often used for initializing ViewModel dependencies via by viewModels() or for creating Retrofit clients. However, be cautious: if a lazy block captures a reference to an Activity or Fragment, it may lead to a memory leak, as the delegate retains the closure for the lifetime of the property.
The choice between lateinit and lazy is not a matter of preference but an architectural decision determined by the nature of the property. Each mechanism solves its own task, and their areas of application only partially overlap.
| Criterion | lateinit | lazy |
|---|---|---|
| Property type | var only | val only |
| Nullable | not allowed | allowed |
| Primitive types | not allowed | allowed |
| Thread safety | not guaranteed | SYNCHRONIZED by default |
| State check | ::x.isInitialized | not required |
| Exception on error | UninitializedPropertyAccessException | error in init block |
| Caching | not applicable | single computation |
| Android Binding | View Binding, Data Binding | not used |
| DI frameworks | Dagger, Hilt, Koin | manual injection |
Use lateinit when a property must change after initialization or its creation is managed by external code. A typical example is View Binding in Android Activity: binding is created in onCreate but remains a var because the framework does not support val for this scenario.
Use lazy when a property is initialized once, its computation is expensive, and the value does not change during the object’s lifetime. A classic example is lazy creation of a Retrofit client or Room database upon first access to the repository.
Both mechanisms can be used simultaneously within a single class. For example, lateinit for View Binding and lazy for a repository. This is a normal practice reflecting different requirements for different properties. The key is not to confuse the semantics: do not use lateinit where val is needed, and do not use lazy for properties that need to be reassigned.
The most common mistake with lateinit is accessing the property before it is initialized. This leads to UninitializedPropertyAccessException, which is not caught at compile time because Kotlin trusts the developer to ensure correct initialization order. The solution is to always check the state via ::property.isInitialized before access in ambiguous situations.
The second common problem is using lateinit for properties that are semantically val. If the value is set once and never changes, lazy is the more correct choice. It makes the property immutable, prevents accidental overwriting, and adds thread-safety for free.
The third mistake is lazy with side effects. The lazy initialization block should not modify external state or depend on the initialization order of other lazy properties, since the computation sequence depends on first access and may be non-obvious. If lazy properties reference each other, it leads to circular dependency and StackOverflowError.
The fourth problem is memory leaks via lazy in Android. If a lazy block captures a reference to an Activity or Fragment, the delegate retains the closure, and the garbage collector cannot free the component even after its destruction. The solution is to use lazy only with short-lived objects or pass the Application context instead of Activity.
The fifth typical mistake is attempting to apply lateinit to primitive types. The Kotlin compiler blocks this at the syntax level, but developers try to bypass the limitation through nullable wrappers. This leads to unnecessary null checks and completely negates the benefits of deferred initialization.
Frequently Asked Questions
lateinit is a modifier for var properties, allowing initialization after the constructor. lazy is a delegate for val properties, computing the value upon first access and caching it. lateinit does not support primitive types and nullable, while lazy is thread-safe by default.
Yes, via the built-in property reference: ::propertyName.isInitialized. The method returns true if the property has been initialized. This is the only safe way to avoid UninitializedPropertyAccessException when working with lateinit fields.
Primitive types — Int, Double, Boolean and others — compile to JVM primitives (int, double, boolean), which have no “not initialized” state. lateinit uses null as a flag, and primitives cannot be null, so the mechanism is physically impossible for these types.
The default is LazyThreadSafetyMode.SYNCHRONIZED — double-checked locking, guaranteeing single initialization under concurrent access from multiple threads. For single-threaded scenarios use NONE, for high contention use PUBLICATION.
When the property must change after initialization or its creation is managed by the framework. A typical example is View Binding in Android Activity: binding is created in onCreate and must be var. For once-initialized val dependencies, use lazy.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also