Kotlin Lambdas, Function References, and Compose: Verify Before Caching

(Updated on )

Correction, September 30, 2026: Lambda allocation and Compose skipping depend on compiler configuration and call-site context. The earlier version overstated singleton guarantees, SAM allocations, and the need to manually remember every callback. This revision removes those claims and fixes the missing callback key in the caching example. No allocation or frame-time improvement is claimed here.

Separate three different questions

Given a function reference and a lambda:

fun label(id: Long) = "Item $id"

val reference: (Long) -> String = ::label
val expression: (Long) -> String = { id -> label(id) }

Ask these questions separately:

  1. Do they perform the intended operation, including the correct captured receiver or values?
  2. What does this Kotlin compiler and backend generate at this particular call site?
  3. Does changing the callback actually affect the measured UI interaction?

Matching function types do not establish object identity. Different source locations are also not a valid test of repeated evaluation at the same source location. Do not make application correctness depend on a function reference or lambda always being a singleton.

Verify JVM lowering against the actual compiler

Kotlin 2.0 made invokedynamic the default generation strategy for ordinary JVM lambdas; compiler flags can change it. See the Kotlin 2.0 release documentation. This does not justify assuming every callable reference has the same lowering, or that a particular runtime allocation is guaranteed.

To inspect a small reproduction, save the functions in Callbacks.kt, record kotlinc -version, then compile and inspect the output:

kotlinc Callbacks.kt -jvm-target 17 -d callbacks-out
javap -c -p -classpath callbacks-out CallbacksKt

These commands are an investigation recipe, not a bytecode dump produced for this article. Also inspect generated classes when present. For an Android app, D8/R8 and the Compose compiler introduce additional transformations, so plain JVM output is only one part of the evidence. Use allocation and frame-time measurements for performance conclusions.

SAM conversion does not imply a fixed allocation count

A functional interface has one abstract method. Kotlin can adapt a lambda or callable reference to such an interface. Capture behavior, compiler backend, optimization, and the call site matter; the source expression alone is not proof that every execution creates a fresh object.

A bound reference such as controller::open depends on controller. If the controller can change, caching its callback forever is a correctness bug even if it avoids some allocations. Decide lifecycle and identity requirements before introducing caching.

Compose: lambda stability and memoization are different

Compose treats lambda types as stable. Whether a particular lambda value changes is a separate question. With Strong Skipping, enabled by default from Kotlin 2.0.20, lambdas inside Composables are automatically memoized using their captures. It is therefore wrong to assume a nested { onUserClick(user) } always recreates its callback and forces the child to recompose.

Start with clear code:

@Composable
fun UserList(users: List<String>, onUserClick: (String) -> Unit) {
    LazyColumn {
        items(users, key = { it }) { user ->
            UserCard(user = user, onClick = { onUserClick(user) })
        }
    }
}

Here each user string must be unique if used as a key. In a real app, use the stable unique ID from the UI model. This is a fragment requiring the usual Compose imports and your UserCard implementation.

Check the compiler report and the runtime behavior before adding manual caching. If manual caching is needed for a particular configuration, include both changing captures:

val onClick = remember(user, onUserClick) {
    { onUserClick(user) }
}
UserCard(user = user, onClick = onClick)

The earlier remember(user) example omitted onUserClick; if the parent supplied a replacement handler while user stayed the same, the cached lambda could call the old handler. Test that scenario explicitly. Manual remember keys and compiler-generated Strong Skipping capture comparisons are not universally interchangeable.

A small regression checklist

  • Recompose the parent with unchanged inputs and inspect the relevant release report
  • Replace the click handler while keeping the same user; clicking must call the replacement
  • Insert and reorder users; row identity and remembered state must remain correct
  • Measure the interaction on the same device and build variant before interpreting a changed count

Use the local Compose report reader to organize report labels, then follow the recomposition investigation guide. A skippable label is compiler eligibility, not a claim of zero recompositions or a faster frame.