Closures and inline in Kotlin, from First Principles
This note is general Kotlin, but it is the bedrock under every Compose lambda you write — remember { }, LaunchedEffect { }, derivedStateOf { }, snapshotFlow { }, lazy-list items { }, and every Modifier that takes a lambda. The day this clicked for me was tracing why a deferred read inside snapshotFlow saw “nothing.” The answer was pure closure semantics, not Compose. Get this model right once and a whole class of bugs stops being mysterious.
Table of contents
- What a closure actually is
- The one rule: lambda bodies run at call-time
varvsvalcapture — value or variable?- Under the hood: where a captured
varlives - The JVM’s “captures must be final” rule, and Kotlin’s workaround
- When boxing happens — and when it doesn’t
inline: pasting the body at the call site- inline vs non-inline: same values, different powers
- Where Kotlin devs trip — the footgun gallery
- Practical guidance
- Interview-grade summary
- Footgun checklist
1. What a closure actually is
The problem it solves. A plain function only knows its parameters and globals. But we constantly want to write a small function now that remembers some context from here, and run it later, somewhere else — a click handler that knows which item it belongs to, a callback that knows the current user, an effect that uses the latest state. The function has to carry its surrounding context with it. That bundle — a function plus the variables it captured from the scope it was created in — is a closure. It “closes over” those variables.
fun makeGreeter(name: String): () -> String {
// `name` is a local of makeGreeter, but the returned lambda uses it.
// The lambda "closes over" name — it carries name with it after makeGreeter returns.
return { "Hello, $name" }
}
val greet = makeGreeter("Sam")
println(greet()) // Hello, Sam ← name survived, because the closure captured it
The single most important question about any capture is: did the closure capture the value of the variable, or the variable itself? That one distinction explains everything in sections 2–9. Hold it in your mind as we go.
2. The one rule: lambda bodies run at call-time
Why this rule matters. People read val f = { println(x) } and feel like x is “read into” f at that line. It is not. Defining a lambda stores the instruction to read x later. The read happens when f() runs.
A lambda’s body executes when it is called, not when it is defined. Every variable read inside the body happens at call-time.
// Minimal
var x = 10
val show = { println(x) } // stores "go read x when called" — reads NOTHING now
show() // (a) reads x now → 10
x = 20
show() // (b) reads x now → 20 ← same lambda, new value
Because the read is deferred to call-time, a single lambda can print two different values across two calls. The lambda is watching the live variable, not a frozen snapshot.
// Realistic: a deferred callback reads state at the moment it fires, not when it was wired up
class Cart {
var total = 0
fun onCheckoutClicked(): () -> Unit = {
// This runs when the user taps, possibly minutes later.
// It reads `total` AT TAP TIME — exactly what we want for a checkout button.
println("Charging $total")
}
}
val cart = Cart()
val handler = cart.onCheckoutClicked() // wired up while total == 0
cart.total = 4200 // user adds items
handler() // Charging 4200 ← call-time read, correct
This is usually the behavior you want. It becomes a footgun only when you assumed the opposite (section 9.1).
3. var vs val capture — value or variable?
The rule. Capturing a val copies the value (a frozen snapshot, taken when the lambda is created). Capturing a var shares the live variable (read — and written — at call-time).
// Minimal — same starting point, two capture kinds, two outcomes
var x = 1
val captured = x // captured = a copy of x's value RIGHT NOW = 1, frozen forever (it's a val)
val f = { println(x) } // closes over the var x → live
val g = { println(captured) } // closes over the val captured → frozen
x = 2
f() // 2 ← live var, read at call-time
g() // 1 ← frozen copy, taken when g was defined
And capturing a var shares it both ways — the lambda can mutate the same cell the outer scope sees:
// Minimal — the lambda writes; the outer scope sees the write
var count = 0
val inc = { count++ } // note: no println → calling inc() prints nothing
inc() // count: 0 → 1
inc() // count: 1 → 2
println(count) // 2 ← lambda and outer scope share ONE cell
// Realistic: a running tally captured by an event stream
fun attachCounter(button: Button): () -> Int {
var clicks = 0
button.onClick { clicks++ } // the handler mutates the shared `clicks`
return { clicks } // the reader sees the same shared `clicks`
}
// Both lambdas close over the SAME `clicks` variable — one writes it, one reads it.
So: val capture = snapshot; var capture = live shared cell. Keep going — how the runtime makes a var a “live shared cell” is where it gets interesting.
4. Under the hood: where a captured var lives
The puzzle that forces the answer. A local variable normally lives in its function’s stack frame and dies when the function returns. Yet this works:
fun makeCounter(): () -> Int {
var count = 0
return { count++ } // the lambda escapes the function
}
val c = makeCounter() // makeCounter has RETURNED — its stack frame is destroyed
println(c()) // 0
println(c()) // 1
println(c()) // 2 ← still mutating `count` after the frame is gone
If count lived on makeCounter’s stack frame, it would be gone after the first line. It isn’t. Conclusion: a captured var cannot live on the stack — it must live on the heap.
Concretely, the Kotlin compiler moves the variable into a small heap wrapper object, and both the enclosing code and the lambda hold a reference to the same wrapper. The wrapper is kotlin.jvm.internal.Ref.IntRef (and friends — ObjectRef<T>, LongRef, BooleanRef, …), which is literally just:
// kotlin.jvm.internal.Ref
public static final class IntRef implements Serializable {
public int element;
}
So this:
fun makeCounter(): () -> Int {
var count = 0
return { count++ }
}
desugars to roughly this:
fun makeCounter(): () -> Int {
val count = Ref.IntRef() // heap allocation; `count` is no longer a stack local
count.element = 0
return Function0 { // the lambda's generated class holds a reference to `count`
count.element++ // mutates the box's field, not a stack variable
}
}
The “local variable” has become the element field of a heap object. Read = element, write = element = …. That object is the “live shared cell” from section 3, made real. The lambda and the enclosing function point at the same IntRef, which is exactly why they see each other’s writes.
5. The JVM’s “captures must be final” rule, and Kotlin’s workaround
The constraint. On the JVM, a lambda captures variables by storing them as final fields in its generated class. Java enforces this directly: you can only capture effectively final locals — count++ inside a captured lambda is a Java compile error. Kotlin lets you write count++. How, without breaking the rule?
The trick, read straight off section 4: the lambda doesn’t capture the Int. It captures a final reference to the IntRef box. The reference never changes — only box.element does. So:
- At the bytecode level Kotlin still captures only a final thing (the box reference). Rule obeyed.
- Mutability moved inside the box. You get
count++.
Java forbids the mutable capture. Kotlin obeys the same JVM rule and still gives you mutation — by adding one level of indirection (mutate the box’s field, not the variable).
The cost of the trick: one heap allocation per captured mutable local, plus a field dereference on every access. Usually negligible — but see section 9.5 for when it isn’t.
6. When boxing happens — and when it doesn’t
A Ref box is not generated for every var. It is generated only for a specific combination:
A
Refbox appears only for a localvarthat is captured by a non-inline closure.
Drop any one of those three conditions and there is no box:
| Case | Boxed? | Why |
|---|---|---|
Local var, captured by a stored/escaping lambda |
✅ yes | must survive the frame and be shared → heap box |
Local val, captured |
❌ no | never mutates → copied by value into a final field |
Local var, not captured |
❌ no | stays an ordinary stack-slot local |
Member var (class field) |
❌ no | the lambda captures this; the field already lives on the heap object — the instance is the box |
Top-level var |
❌ no | compiled to a static field; lambda reads/writes it via the static getter/setter |
| Function parameter | ❌ no | Kotlin params are val → captured by value |
var top = 0 // top-level → static field
class C {
var member = 0 // field on the instance (heap)
fun f() {
var local = 0 // candidate for boxing
val a = { local++ } // ✅ local var captured by a stored lambda → Ref.IntRef
val b = { member++ } // ❌ captures `this`; mutates this.member (no Ref)
val c = { top++ } // ❌ static field; this lambda captures NOTHING
}
}
One under-appreciated consequence (it explains a real bug class in section 9.6): a lambda that touches a member doesn’t box the field — it captures this, the whole enclosing instance.
7. inline: pasting the body at the call site
The problem inline solves. Every non-inline lambda is a heap object (a FunctionN instance), and capturing locals adds Ref boxes. For a one-off helper called in a tight loop, that’s allocation and an extra virtual call per element — pure overhead for something that’s conceptually just “run this code here.” inline removes the overhead by not creating a function at all.
When you mark a higher-order function inline, the compiler pastes the function’s body — and the lambda’s body — directly into the call site. There is no FunctionN object, no invoke() call, and a captured local stays an ordinary stack local accessed directly (no box).
inline fun <T> measured(label: String, block: () -> T): T {
val start = System.nanoTime()
val result = block() // ← block's body is spliced in right here
println("$label: ${System.nanoTime() - start}ns")
return result
}
fun load() {
var bytes = 0
measured("io") { bytes = readFile() } // no lambda object; no Ref box for `bytes`
println(bytes)
}
After inlining, load looks (roughly) like:
fun load() {
var bytes = 0
val start = System.nanoTime()
bytes = readFile() // the lambda body, pasted in; `bytes` is a plain local
println("io: ${System.nanoTime() - start}ns")
println(bytes)
}
This is why the Kotlin standard library makes forEach, map, filter, let, run, also, apply, with, repeat all inline — and why Compose marks Box, Column, lazy items {}, and many Modifier builders inline. The lambdas you pass them allocate nothing.
8. inline vs non-inline: same values, different powers
Here is the surprise that trips even experienced devs: for variable reads and writes, inline and non-inline produce identical output. The Ref box is about where the variable lives, never what value you read. Inlining does not copy the variable into a private copy — it splices code that accesses the same variable.
// NON-INLINE: stored lambda → real Function object + Ref.IntRef box
fun nonInline() {
var x = 0
val add: () -> Unit = { x++ } // x lives in an IntRef on the heap
add(); add(); add()
println(x) // 3
}
// INLINE: repeat is inline → code spliced in, x stays a plain stack local, no box
fun inlineVer() {
var x = 0
repeat(3) { x++ } // mutates the same x, directly
println(x) // 3
}
Both print 3. Same semantics; the only difference is bytecode (one allocates a FunctionN + IntRef, the other allocates nothing).
Why inline can safely skip the box: an inline lambda cannot escape. You can’t store it in a variable, return it, or pass it to a non-inline function (the compiler rejects it unless you mark it noinline). Because it can’t escape, the captured var never outlives the frame → it can stay on the stack → no box needed. The box exists precisely for the escape case, and escape requires a non-inline lambda.
Where the difference is real — control flow, not values. Inlining unlocks language features that genuinely change behavior:
fun firstEven(nums: List<Int>): Int? {
nums.forEach { // forEach is INLINE
if (it % 2 == 0) return it // NON-LOCAL return: returns from firstEven(), not just the lambda
}
return null
}
That bare return exits firstEven. Through a non-inline lambda it would be a compile error — you’d be forced to write return@forEach, which only skips the current element. So:
| Dimension | Inline closure | Non-inline closure |
|---|---|---|
Ref box for captured local var |
❌ none (stays stack local) | ✅ allocated (Ref.IntRef) |
FunctionN object allocated |
❌ no (spliced in place) | ✅ yes |
| Can escape (stored/returned)? | ❌ no | ✅ yes |
Non-local return / break / continue |
✅ allowed → can change control flow | ❌ compile error (only return@label) |
| reified type parameters | ✅ allowed | ❌ n/a |
| Output of captured-var reads/writes | same | same |
The knobs: noinline forces one lambda parameter to be a real, escapable object (boxing returns); crossinline keeps it inlined but forbids non-local returns (needed when you pass the lambda into another execution context, like a Runnable).
Mental model. Inline = paste the body in place → no object, no box, direct variable access. Non-inline = real heap object → captured mutable locals get a
Refbox to survive and be shared. Same values out either way; boxing decides where the variable lives, never what it reads.
9. Where Kotlin devs trip — the footgun gallery
Every bug below is a misread of one of two things: when the body runs (call-time, §2) or what got captured (value vs variable, §3).
9.1 The deferred read — “why is it the latest value?”
You build lambdas in a loop and expect each to remember “its” value, but they all read the shared variable at call-time and see the final value.
// BUG: one shared `var i` (one Ref box) captured by all three lambdas
val fns = mutableListOf<() -> Int>()
var i = 0
while (i <= 2) {
fns += { i } // every lambda closes over the SAME i
i++
}
println(fns.map { it() }) // [3, 3, 3] ← not [0, 1, 2]! all read i at call-time, i is now 3
The fix is to capture a fresh value per iteration. Kotlin’s for-loop variable is a fresh read-only binding each iteration, so it just works:
// FIX: for-loop variable is a fresh val each iteration → each lambda freezes its own value
val fns = mutableListOf<() -> Int>()
for (i in 0..2) {
fns += { i }
}
println(fns.map { it() }) // [0, 1, 2]
Or copy into a local val inside the loop body: val snapshot = i; fns += { snapshot }.
9.2 Stale captures in long-lived lambdas (the Compose classic)
The mirror image of 9.1: you capture a val (frozen snapshot), but you wanted the latest value. This is the canonical Compose effect bug.
// BUG: `onTimeout` is captured by value when LaunchedEffect first runs.
// If the parent recomposes with a new onTimeout, this still calls the OLD one.
@Composable
fun AutoLogout(onTimeout: () -> Unit) {
LaunchedEffect(Unit) {
delay(5_000)
onTimeout() // stale: the onTimeout from the first composition
}
}
// FIX: rememberUpdatedState wraps it in a State that is rewritten on every recomposition;
// the lambda reads `.value` at CALL-TIME → always the latest. (This is the §2 deferred-read,
// turned into a feature: read a mutable holder at call-time instead of freezing a value.)
@Composable
fun AutoLogout(onTimeout: () -> Unit) {
val current by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(5_000)
current() // fresh: reads the State's latest value when the delay completes
}
}
rememberUpdatedState is nothing magical — it is “I need call-time semantics, so I’ll read a mutable cell instead of a frozen val.”
9.3 “inline must change my output” — it doesn’t
A common misconception: “the inline version has no box, so it must work on a copy and behave differently.” No. Inlining splices code that touches the same variable; §8 showed both versions print 3. If you switch a function between inline and non-inline and the values change, something else is wrong — boxing never changes values.
9.4 Non-local return from forEach
// SURPRISE: this returns from the whole function on the first match, not "skip this element"
fun process(items: List<Item>) {
items.forEach {
if (it.isPoison) return // forEach is inline → NON-LOCAL → exits process()
handle(it)
}
cleanup() // ← never runs if any item is poison
}
If you meant “skip this one,” write return@forEach. If you meant “stop early but still run cleanup,” restructure (e.g., firstOrNull, or a real loop with break). The bug is silent because it compiles fine and usually does something plausible.
9.5 Hidden allocations in hot paths
A non-inline lambda that captures state allocates a FunctionN object (and Ref boxes for mutable captures) every time it is created. In a per-frame or per-list-item path, that’s real GC pressure.
// BUG: a fresh capturing lambda + IntRef allocated on every frame
fun onFrame(node: Node) {
var acc = 0
node.children.customForEachNonInline { acc += it.weight } // non-inline helper
node.weight = acc
}
Two fixes: make the helper inline (zero allocation), or — if it must stay non-inline — avoid capturing (a lambda that captures nothing is compiled to a singleton, so it doesn’t allocate per call). Note the nuance: non-capturing non-inline lambdas are cheap (singleton); capturing ones are not.
9.6 Accidentally capturing this → memory leaks
From §6: a lambda that touches a member field/method captures the entire enclosing instance, not just the field. If that lambda outlives the instance, you leak the instance — the textbook Android Activity/View leak.
// BUG: the lambda reads `userName` (a member) → it captures `this@ProfileActivity`.
// Stored in a process-lifetime singleton → the Activity can never be GC'd.
class ProfileActivity : Activity() {
private val userName = "Sam"
fun register() {
EventBus.onLogin = { greet(userName) } // leaks this ProfileActivity
}
private fun greet(n: String) { /* ... */ }
}
// FIX: don't capture the instance. Pull out the value you actually need first.
class ProfileActivity : Activity() {
private val userName = "Sam"
fun register() {
val name = userName // capture a String by value, not `this`
EventBus.onLogin = { greet(name) } // (and clear EventBus.onLogin in onDestroy)
}
}
Rule of thumb: if a long-lived lambda references any member of a short-lived object, you’re capturing that object. Capture the specific value, or use a weak reference, or clear the callback on teardown.
9.7 var captured by a coroutine/lambda across threads
A captured var is a single shared cell (an IntRef) with no synchronization. Mutating it from multiple coroutines/threads is a data race — and atomic-izing the value won’t help, because the problem is unsynchronized shared mutable state, not a torn read. Use a thread-safe holder (AtomicInteger, a Mutex, or confine the mutation to one dispatcher).
10. Practical guidance
- Default to
inlinefor higher-order utility functions whose lambdas run synchronously and don’t escape. Zero allocation, and you get non-local returns for free. Don’t inline large function bodies called from many sites (code bloat) or functions that mainly exist to store/forward the lambda. - Reach for
crossinlinewhen an inline function passes the lambda into another execution context (aRunnable, a builder) — you keep inlining but forbid the non-local return that would otherwise be meaningless. - Reach for
noinlinewhen one lambda parameter must be stored, returned, or passed to a non-inline API. - In Compose, assume composable lambdas and effect lambdas have lifetimes longer than the call that created them. If you need the latest value inside a long-lived lambda, read a
State(often viarememberUpdatedState) rather than capturing a parameter by value. - Before storing a lambda long-term, ask: what does it capture? If it touches members of a short-lived object, you’re holding that object. Capture values, not instances.
- In hot paths, prefer non-capturing lambdas (singletons) or inline functions; be aware that each capturing non-inline lambda is an allocation.
11. Interview-grade summary
- A closure = a function + the variables it captured from its defining scope.
- A lambda’s body runs at call-time; every variable read inside happens then, not at definition.
- Capturing a
valcopies the value (frozen snapshot). Capturing avarshares the live variable (read and written at call-time). - A captured local
varcan’t live on the stack (it may outlive the frame), so the compiler moves it into a heapRefbox (Ref.IntRef,ObjectRef, …); the lambda holds afinalreference to the box and mutatesbox.element. - That’s how Kotlin permits
count++in a lambda while obeying the JVM’s “captures are final” rule: the captured reference is final; mutability moved into the box. - Boxing happens only for a local
varcaptured by a non-inline closure.vals are copied; member vars capturethis; top-level vars are static fields; inline captures access the variable directly. inlinepastes the function and lambda bodies into the call site — noFunctionNobject, no box, plus non-local returns and reified generics.- inline vs non-inline gives identical variable values; differences are allocation and control-flow features, never the value read.
- Most footguns are a misread of when (call-time) or what (value vs variable) — loop-capture, stale Compose captures, accidental
thiscapture, non-local returns, hot-path allocations.
12. Footgun checklist
- Building lambdas in a
while/for(var)loop and expecting each to freeze “its” value — they share one cell and read the final value at call-time. (Use afor-loop binding or copy to a per-iterationval.) - Capturing a composable parameter by value in a long-lived effect and getting a stale value — use
rememberUpdatedState/ read aState. - Assuming inline vs non-inline changes the value read — it never does; it changes allocation and control flow.
- A bare
returninside an inlineforEach/let/runexiting the whole function unexpectedly — usereturn@labelfor local. - A capturing non-inline lambda in a per-frame / per-item path — hidden
FunctionN+Refallocations; make it inline or non-capturing. - A long-lived lambda touching a member of a short-lived object (Activity/View/ViewModel) — you captured
this; leak. Capture the value, or clear the callback. - Mutating a captured
varfrom multiple coroutines/threads — unsynchronized shared cell; use a thread-safe holder or confine it.
Related notes / what to learn next
compose-component-identity.md— the slot table and why captured lambdas in composables interact with positional identity.- The snapshot system (
readObserver/registerApplyObserver,snapshotFlow) — the deferred-read-at-call-time idea, applied to state observation. Closures are the substrate; the snapshot machinery is what reads them at the right moment.