Kotlin lazy {} Has 3 Modes and You're Probably Using the Wrong One
TL;DR
Kotlin’s lazy {} delegate ships with three thread-safety modes: SYNCHRONIZED, PUBLICATION, and NONE. The default is SYNCHRONIZED, which wraps initialization in a lock. If your property is only accessed from a single thread (like nearly everything in Jetpack Compose) you’re paying for synchronization you don’t need. This is a concurrency design decision, not an Android-vs-Compose one.
The default is safe, not free
When you write this:
val userProfile by lazy { loadProfile() }
Kotlin compiles it as lazy(LazyThreadSafetyMode.SYNCHRONIZED) { loadProfile() }. Under the hood, this uses a synchronized block with a lock object to guarantee that exactly one thread executes the initializer, and all other threads see the result.
That’s a reasonable default for a general-purpose language. But “safe by default” has a cost. In my experience building production systems, the majority of lazy properties in an Android codebase live on the main thread: ViewModels, Composables, UI state holders. They will never be touched by a second thread. Yet every access still checks a volatile field and contends with lock semantics.
Let’s look at all three modes.
The three modes compared
| Mode | Thread safety | Initializer runs | Lock overhead | Best for |
|---|---|---|---|---|
SYNCHRONIZED | Full — one thread inits, others block | Exactly once | synchronized block per access until init | Shared mutable state across threads |
PUBLICATION | Partial — multiple threads may init | Possibly multiple times, but only one result kept | Atomic reference (CAS) | Idempotent initialization, concurrent reads |
NONE | None | Exactly once (caller’s responsibility) | Zero | Single-thread access (UI, main thread) |
When to use which
NONE — the Compose and UI default you should adopt
val headerStyle by lazy(LazyThreadSafetyMode.NONE) {
TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold)
}
If the property is created and read on the main thread, NONE is the right choice. This covers ViewModels consumed by Compose, UI configuration objects, style definitions, and screen-scoped state. Zero synchronization, zero overhead. The initializer runs once on first access, and there’s no locking machinery involved.
Most teams get this wrong: they assume NONE is “unsafe” and therefore risky. It’s only unsafe if a second thread accesses the property before or during initialization. In a single-threaded context, that can’t happen. Choosing NONE isn’t cutting corners. It’s expressing your concurrency contract explicitly.
SYNCHRONIZED — shared state across threads
val dbConnection by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
DatabaseDriver.open("app.db")
}
Use this when multiple threads (coroutine dispatchers, background workers, DI graphs) may race to access the property. The lock guarantees one winner initializes, and everyone else waits. This is the right tool for singletons in concurrent environments, connection pools, or shared caches.
PUBLICATION — the underused middle ground
val defaultConfig by lazy(LazyThreadSafetyMode.PUBLICATION) {
Config.loadDefaults() // pure, idempotent
}
PUBLICATION allows multiple threads to execute the initializer concurrently, but only one result is atomically published and retained. The others are discarded. This avoids the blocking that SYNCHRONIZED introduces while still converging on a single instance.
The tradeoff: your initializer must be idempotent and side-effect free. If loadDefaults() reads a file and returns a data class, fine. If it opens a socket, not fine.
A practical heuristic
The architecture decision comes down to one question:
How many threads can access this property?
- One thread (UI/main):
NONE. - Multiple threads, initializer has side effects:
SYNCHRONIZED. - Multiple threads, initializer is pure:
PUBLICATION.
It’s not about whether you’re writing Compose or classic Views. It’s about your threading model. A ViewModel property accessed only from Dispatchers.Main is single-threaded, regardless of whether it’s consumed by XML layouts or @Composable functions. Even in a side project like HealthyDesk, a break-reminder app I built with Compose where virtually all state lives on the main thread, switching UI-scoped lazy properties to NONE was a trivial, zero-risk cleanup.
Does it actually matter for performance?
On a single property? No. Across a codebase with hundreds of lazy-initialized properties in ViewModels, repositories, and UI modules? The cumulative overhead of unnecessary synchronized blocks adds up, especially during app startup when many lazy properties initialize in rapid succession. I’ve seen profiler traces on cold starts where lock contention shows up as a real contributor when SYNCHRONIZED is used indiscriminately.
This isn’t premature optimization. It’s choosing the right tool for the concurrency contract you already have.
What to do next
Search for by lazy { in your codebase. For every property that’s only accessed from the main thread, switch to lazy(LazyThreadSafetyMode.NONE). Treat the mode parameter as documentation of your threading contract, not a performance toggle. Reserve SYNCHRONIZED for state that’s genuinely shared across threads, and reach for PUBLICATION when initializers are pure but access is concurrent. Stop paying for locks you don’t need.