← All articles

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

  1. What a closure actually is
  2. The one rule: lambda bodies run at call-time
  3. var vs val capture — value or variable?
  4. Under the hood: where a captured var lives
  5. The JVM’s “captures must be final” rule, and Kotlin’s workaround
  6. When boxing happens — and when it doesn’t
  7. inline: pasting the body at the call site
  8. inline vs non-inline: same values, different powers
  9. Where Kotlin devs trip — the footgun gallery
  10. Practical guidance
  11. Interview-grade summary
  12. 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:

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 Ref box appears only for a local var that 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 Ref box to survive and be shared. Same values out either way; boxing decides where the variable lives, never what it reads.


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


11. Interview-grade summary


12. Footgun checklist