Engineering

remember is not a cache, and the key is the whole contract

It is scoped to a composition and keyed by its arguments. A missing key gives stale state; a wrong one recomputes every frame.

MobileByteSensei 8 min read
composeperformanceandroid

TL;DR

remember stores a value in the composition's slot table, at the position where the call appears, keyed by the arguments you pass it. It is not a cache keyed by content and it has no eviction policy — its lifetime is the lifetime of that call site in that composition.

Two failure modes follow. No key means the value is computed once and never recomputed, so it goes stale when its inputs change. A key that is a new object on every recomposition means the value is recomputed every time, which is the expensive version of not using remember at all.

The key is the contract: it declares exactly when the value should be rebuilt.

The case this post is about

A filtered, sorted list derived from two inputs:

@Composable
fun ItemList(items: List<Item>, query: String, sort: SortOrder) {
    val visible = remember {                           // no key
        items.filter { it.title.contains(query, ignoreCase = true) }
             .sortedWith(sort.comparator)
    }
    LazyColumn { items(visible, key = { it.id }) { ItemRow(it) } }
}

And a formatter alongside it:

val formatter = remember { DateTimeFormatter.ofPattern("d MMM", locale) }

Both compiled. Both looked like sensible memoisation. One never updated and the other was fine until the locale changed.

The symptom

The list never filtered. Typing in the search box changed query and the list did not move:

query = ""      → 60 items
query = "kot"   → 60 items      (expected 4)
query = "xyzzy" → 60 items

Then a different screen had the opposite problem. After someone "fixed" a similar block by adding a key, scrolling became janky:

val visible = remember(items.filter { it.isActive }) { … }    // ← a NEW list every recomposition
scroll 60 items
  frame P95   84ms      recomputation on every frame

And a third variant produced a crash after rotation:

val expanded = remember { mutableStateOf(false) }   // lost on rotation, which was fine
val pager = remember { Pager(config, source) }      // recreated, mid-flight requests orphaned

Three bugs: stale, over-eager, and wrong-lifetime. All three are the key.

Why the obvious fix failed

"Add the inputs as keys." The right answer, and the first attempt passed a computed value as the key — remember(items.filter { … }) — which allocates a fresh list each recomposition, so the key never equals its predecessor. The lambda runs every time, plus you now pay for the filter twice.

"Use derivedStateOf instead." The right tool for a value read during composition from State sources, and it is not a drop-in replacement: it needs its inputs to be State, and it changes when invalidation propagates rather than caching across recompositions. Reaching for it without understanding the distinction produced a version that was correct and no faster.

"Use rememberSaveable." Fixes the rotation case for savable types and does nothing for the staleness, and a Pager is not savable anyway.

"Hoist the computation into the ViewModel." Correct for this case, and worth saying why: the filtered list is derived state that does not depend on composition at all. That realisation is the real fix — but it does not teach you what remember does, and the next genuinely composition-scoped value will hit the same wall.

The actual mechanism

remember has three properties, and each failure maps to one.

It is positional. The value is stored in the slot table at the position of the call in the composition tree. Two remember calls in the same composable occupy different slots; the same call in two instances of a composable occupies two slots. There is no global namespace and no content addressing.

It is keyed by equals on its arguments. On each recomposition, Compose compares the passed keys to the stored ones. Equal → return the stored value. Not equal → run the lambda and store the new result. No arguments means "no keys", which compares equal forever.

Its lifetime is the call site's presence in composition. Leave the composition — navigate away, leave a LazyColumn's cache, rotate without rememberSaveable — and the slot is discarded. There is no eviction policy because there is no cache; there is a tree, and the value lives as long as that node does.

rememberSaveable extends the lifetime without changing any of the above. It is the natural next reach — it survives rotation and process death — and it is still positional, still keyed by equals, and still discarded when the call site leaves composition. What it adds is serialisation through the platform's saved-state bundle, which brings two constraints of its own:

rememberSaveable { mutableStateOf(query) }                 // String — fine
rememberSaveable { mutableStateOf(searchResults) }         // List<Result> — runtime failure

The second throws unless the type is Parcelable/Serializable or you supply a custom Saver. And the saved-state bundle is small — a platform-level limit measured in hundreds of kilobytes across the whole activity, not a per-key budget — so a list that happens to serialise cleanly today is a crash when a user has two hundred results instead of ten.

So it widens the lifetime by one axis and inherits every other property, which is why it is not the answer to "my expensive computation keeps re-running". That is a question about caching, and the correct home for a cache is a component whose lifetime you chose deliberately — a ViewModel, a repository, a Store — rather than the composition tree, whose lifetime is decided by navigation and scrolling.

remember storing a value in the composition slot table at its call position, with its keys compared by equals on each recomposition: no keys compares equal forever and returns a stale value, a freshly allocated key compares unequal every time and re-runs the lambda on every frame, and the correct keys re-run it exactly when an input changed

remember(k1, k2) { … } reads as: recompute this exactly when k1 or k2 changes. The keys are not a hint or an optimisation — they are the declaration of when the value is invalid.

The fix

Key by the stable inputs, and move genuinely derived state out of composition.

The filter and sort belong to the ViewModel, because they depend on nothing about the UI:

// ViewModel — derived once per input change, not per composition
val visible: StateFlow<ImmutableList<Item>> =
    combine(items, query, sort) { items, q, s ->
        items.filter { it.title.contains(q, ignoreCase = true) }
             .sortedWith(s.comparator)
             .toImmutableList()
    }.stateIn(viewModelScope, WhileSubscribed(5_000), persistentListOf())
@Composable
fun ItemList(vm: ItemListViewModel) {
    val visible by vm.visible.collectAsStateWithLifecycle()
    LazyColumn { items(visible, key = { it.id }) { ItemRow(it) } }
}

Where a value genuinely is composition-scoped, key it by stable inputs:

// correct: `locale` is a stable value, not a freshly computed object
val formatter = remember(locale) { DateTimeFormatter.ofPattern("d MMM", locale) }

// correct: an expensive derivation from parameters, keyed by those parameters
val layout = remember(width, itemCount) { computeGrid(width, itemCount) }

Three rules that cover almost every case:

Keys must be cheap to compare and stable. A String, an Int, a value class, a stable data class. Never a freshly allocated collection or lambda — that is the over-eager failure.

No keys means "for the lifetime of this node, unconditionally". That is correct for a mutableStateOf initial value, a CoroutineScope, or anything that should be created once per screen entry. It is wrong for anything derived from a parameter.

derivedStateOf is for narrowing invalidation, not for caching across recompositions:

// correct use: a cheap computation over State, read in composition,
// where you want recomposition only when the RESULT changes
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 5 }
}

Without derivedStateOf, reading firstVisibleItemIndex directly recomposes on every scroll pixel. With it, recomposition happens only when the boolean flips. That is the distinction: remember caches across recompositions, derivedStateOf reduces how many recompositions happen.

And the right lifetime for the Pager:

// the Pager belongs to the ViewModel — it holds in-flight requests and must
// survive rotation, which composition does not.
class ItemListViewModel : ViewModel() {
    val paged = Pager(config) { source }.flow.cachedIn(viewModelScope)
}

How we proved it

That the list now filters — the test the original code would have failed:

@Test
fun filtersAsTheQueryChanges() {
    composeTestRule.setContent { ItemList(vm) }
    composeTestRule.onAllNodesWithTag("row").assertCountEquals(60)

    vm.onQueryChange("kot")
    composeTestRule.waitForIdle()
    composeTestRule.onAllNodesWithTag("row").assertCountEquals(4)
}
remember { } with no key:  FAILED — expected 4, found 60
after:                     PASSED

That the computation runs the right number of times. This is the assertion that distinguishes all three bugs from each other:

@Test
fun derivationRunsOncePerInputChangeNotPerRecomposition() {
    var runs = 0
    composeTestRule.setContent {
        var tick by remember { mutableStateOf(0) }
        val v = remember(query) { runs++; derive(query) }    // query fixed in this test
        LaunchedEffect(Unit) { repeat(30) { tick++; awaitFrame() } }
        Text("$v$tick")
    }
    composeTestRule.waitForIdle()
    assertEquals(1, runs)        // 30 recompositions, one derivation
}
remember(items.filter { … }) { … }:  FAILED — runs = 31
remember(query) { … }:               PASSED — runs = 1

And the frame timing on the over-eager version:

scroll 60 items
  remember(freshly-computed-key)   frame P95  84.1ms
  remember(stable-key)             frame P95   8.3ms
  derived in ViewModel             frame P95   7.9ms

One limit: the runs counter test uses a mutable var captured from a composable, which is fine for counting in a test and is not something to ship. And the frame numbers are one device and one list size — the useful comparison is the ratio between the three rows, not the absolute values.

What this generalizes to

A memoisation key is a declaration of invalidity. Not an optimisation hint — a statement of when the cached value stops being true. Getting it wrong in one direction gives stale data and in the other gives no caching, and both look like the code "has memoisation".

Never allocate a key. A key compared by equals must be something that can actually be equal to its previous value. Freshly constructed collections, lambdas and objects are the standard way to accidentally disable the mechanism you just added.

Match the lifetime to the owner. A value scoped to a composition dies with the composition. If it must survive rotation, process death, or navigation, the composition is the wrong owner — and no amount of keying changes that.

Derived state usually does not belong in the view layer. The filter and sort depended on nothing about the UI. When a memoised computation inside a component takes only parameters as input, it is often a sign the computation belongs above the component entirely.

Keep reading

This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS