Most Compose developers learn remember in their first hour, and many keep reaching for it long after it stops helping. The habit hardens into a reflex: if a value is computed in a composable, wrap it. Review a few production codebases and you will find remember around boolean checks, string concatenations, and sums of two integers.
This post lays out what a remember call does at runtime, which workloads justify it, which don't, and the bugs that show up when it is misused. In my experience, most of those bugs come from over-using remember, not from forgetting it.
What happens at a remember call site
remember is an inline function, so your trailing block is not allocated as a function object. At each call site the compiler produces logic that behaves roughly like this:
// What you write
val sections = remember(transactions) {
transactions.groupBy { it.date }.toSortedMap(reverseOrder())
}
// Roughly what executes (simplified; mirrors Composer.cache in the runtime)
val dirty = composer.changed(transactions) // compares against the key kept in the slot table, updates it if different
val stored = composer.rememberedValue() // then reads the value slot
val sections = if (dirty || stored === Composer.Empty) {
transactions.groupBy { it.date }.toSortedMap(reverseOrder())
.also { composer.updateRememberedValue(it) }
} else {
stored as SortedMap<LocalDate, List<Transaction>>
}
The key comparison is the argument to cache, so it is evaluated before the value slot is read. The comparison itself goes through changed, which relies on equals().
Three separate costs fall out of that:
-
Reuse cost. One
equals()-based comparison per key against the key stored in the slot table, plus one read of the value slot. You pay this on every composition pass that reaches the call site, even when nothing changed. (Mostequals()implementations return quickly for the same instance, but that is a property of the key type, not somethingrememberguarantees.) - Recompute cost. The reuse cost, plus your block, plus a slot write.
- Standing cost. The stored value and its keys stay referenced for as long as the call site remains in the composition. This is a memory cost, and it is the one people overlook.
The reuse cost is small: roughly tens of nanoseconds on current hardware. I am deliberately stating an order of magnitude and nothing tighter. The real figure depends on how many keys you pass, how expensive their equals() is, and the device, so benchmark your own case instead of trusting any number in an article.
Cost spectrum
The question to ask is not “is this slow in absolute terms?” It is “is this slow compared with what I pay on every pass, and does anything downstream depend on the result’s identity?”
Cases where it earns its place
Grouping, sorting, and reshaping collections
@Composable
fun TransactionFeed(transactions: List<Transaction>, currency: Currency) {
// Tracks per-app language switching; the compat call also covers minSdk < 24
val locale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0) ?: Locale.getDefault()
// A miss costs a pass over every row plus map allocation.
// A hit costs a slot read and one key comparison.
val sections = remember(transactions) {
transactions
.groupBy { it.date }
.toSortedMap(compareByDescending { it })
}
// Locale is a key, otherwise the formatter would keep the old language after a switch
val money = remember(currency, locale) {
NumberFormat.getCurrencyInstance(locale).apply { this.currency = currency }
}
LazyColumn {
sections.forEach { (date, rows) ->
stickyHeader(key = date) { DayHeader(date) }
items(rows, key = { it.id }) { TransactionRow(it, money) }
}
}
}
Grouping, sorting, mapping to display models, and building formatted text from collections are all comfortably above the cutoff.
Three notes on this sample. First, the locale lookup is a cheap read, so it is deliberately not wrapped in remember. Second, anything derived from the environment (locale, density, layout direction) belongs in the key list, as the formatter shows. Third, on older foundation releases stickyHeader is experimental and needs @OptIn(ExperimentalFoundationApi::class); recent releases no longer require it, so check the version you ship.
Objects that are costly to build but cheap to use
Formatters, Regex, Paint, Path, and gradient Brush objects fit this pattern.
@Composable
fun RingMeter(progress: Float, colors: List<Color>) {
val sweep = remember(colors) { Brush.sweepGradient(colors) }
Canvas(Modifier.size(96.dp)) {
drawArc(
brush = sweep,
startAngle = -90f,
sweepAngle = 360f * progress,
useCenter = false,
style = Stroke(width = 10.dp.toPx(), cap = StrokeCap.Round),
)
}
}
One refinement: if the object has no inputs at all and is immutable, thread-safe, and independent of context, configuration, and density, skip remember and hoist it to a private val at file level. It is built once per process instead of once per call site.
private val HexColor = Regex("^#[0-9a-fA-F]{6}$") // beats remember { Regex(...) }
That condition is strict, and it rules out several objects from the list above. Path and Paint are mutable, so a shared instance invites cross-composable interference (a reset() in one place corrupts another). NumberFormat and SimpleDateFormat are not thread-safe and follow the locale. Anything sized by density or layout belongs in remember with the right keys. Keep those at the call site, and hoist only the plain immutable constants.
Allocations whose identity matters downstream
A primitive sum allocates nothing. A freshly built Brush, List, or PaddingValues on every pass allocates, and it also gives each child a new instance to compare. If the child takes that instance as a parameter of an unstable type, or you are not running with Strong Skipping, the child recomposes each time.
This is why a pure “under 1 µs, skip it” rule is too blunt. The better test has two halves: is the work heavy, or does a new instance change what downstream code sees?
Cases where it is dead weight
Trivial expressions
// ❌ Each of these costs less than the slot machinery wrapped around it
val isEmpty = remember(items) { items.isEmpty() }
val total = remember(a, b) { a + b }
val isLoading = remember(state) { state is UiState.Loading }
// ✅
val isEmpty = items.isEmpty()
val total = a + b
val isLoading = state is UiState.Loading
Property reads, type checks, equality checks, and simple arithmetic all belong here. The wrapped version is longer to read and adds a slot to the table to save nothing.
Callbacks, once Strong Skipping is on
// ❌ Redundant with Strong Skipping
val onRetry = remember { { presenter.reload() } }
// ✅
val onRetry = { presenter.reload() }
Strong Skipping is the default from Kotlin 2.0.20 with the Compose compiler plugin. It lets composables with unstable parameters skip, and it auto-remembers lambdas created inside composable functions. Captures that are stable are compared with equals(); unstable ones are compared by reference (===), which the compiler does through an identity-based variant of the key check (changedInstance) rather than plain changed.
Two limits to keep in mind:
- The automatic memoization applies to lambdas created within composable functions. A lambda built in a plain class or produced by a top-level factory gets no such treatment.
-
@DontMemoizeturns it off for a given lambda, and@NonSkippableComposableturns off skipping for a composable. If you have used either, the "write it plainly" advice no longer applies at that site.
The hand-rolled remember here is also riskier than the redundant one, as the first failure mode below shows.
Decision flow
Failure modes
1. Stale capture from an unkeyed block
This is the bug I see most often, and it is the dark side of “memoize my callback”.
@Composable
fun TaskRow(task: Task, onToggle: (TaskId) -> Unit) {
// ❌ Pins the first task's id and the first onToggle for as long as this call site lives
val toggle = remember { { onToggle(task.id) } }
Checkbox(checked = task.done, onCheckedChange = { toggle() })
}
After a reorder or a data refresh, the checkbox toggles the wrong task. Strong Skipping cannot save you, because an explicit unkeyed remember asks for a value that never invalidates. Delete it, or key it on every captured value. For effects that run for a long time and must see the newest callback, reach for rememberUpdatedState.
2. A key that is mutated in place
remember invalidates when equals() says the key changed, and an instance always equals itself.
val picked = remember { mutableListOf<TagId>() }
// ❌ Same instance forever, so this summary is computed once
val summary = remember(picked) { picked.sorted().joinToString() }
// ✅ Snapshot-aware list, with a derived value that tracks reads
val picked = remember { mutableStateListOf<TagId>() }
val summary by remember { derivedStateOf { picked.sorted().joinToString() } }
A plain MutableList also gives Compose no signal to recompose, so you end up with stale UI and a stale cache together.
3. A key that costs as much as the work
List.equals compares element by element. If the caller rebuilds an equal-but-new list on every pass, your key check traverses all of it.
// ❌ Equal-but-new list each pass: an O(n) comparison to avoid an O(n) map
val cards = remember(articles) { articles.map(::toCardModel) }
On a miss you can pay the comparison up to the first difference and then the full recompute, so the cache is a wash at best. Two fixes beat a clever key:
- Produce
cardsupstream (a ViewModel, or aStateFlowwithmap) so the composable receives the finished value. - Or key on something cheap that changes whenever the data does: a revision counter, a version id, or a stable content hash computed once when the data is created.
4. Heavy work still blocks the main thread
remember makes repeats cheap. It does nothing about the first run, which happens synchronously during composition.
// ❌ Highlighting a large file during the first composition delays the frame
val highlighted = remember(source) { SyntaxHighlighter.highlight(source) }
// ✅ Move it off the main thread, with an explicit loading state
val highlighted by produceState<AnnotatedString?>(initialValue = null, source) {
value = withContext(Dispatchers.Default) { SyntaxHighlighter.highlight(source) }
}
produceState runs its coroutine on the composition's dispatcher, so the withContext hop is what moves the work off the main thread. When source changes, the previous coroutine is cancelled, but cancellation only takes effect if the work cooperates, so call ensureActive() inside long loops in the highlighter.
If several screens need the same result, a ViewModel is usually a better home than any composable.
5. State vanishes when the call site leaves composition
remember survives recomposition. It does not survive an item scrolling out of a LazyColumn, a destination popping off the back stack, or an activity being recreated.
// Cleared on rotation, and when the item scrolls away and returns
var draft by remember { mutableStateOf("") }
// Survives configuration change and process death (for Saver-compatible types)
var draft by rememberSaveable { mutableStateOf("") }
State that has to outlive the UI element entirely should be hoisted into a state holder or ViewModel.
6. Lingering references
The slot retains the value and the keys. Remember a decoded image, a large collection, or anything that captures an Activity, and it stays reachable while the call site lives.
// ❌ Holds a full-size bitmap for as long as the call site lives
val thumbnail = remember(uri) { ThumbnailLoader.decode(context, uri) }
The remembered value is what stays alive, so make sure the decoded result does not itself hold the Activity. Pass LocalContext.current.applicationContext into loaders that keep a reference.
Narrow the scope, prefer an image-loading library built for this job, and if the object owns a resource, implement RememberObserver (or use DisposableEffect) so cleanup runs in onForgotten or onDispose.
remember and derivedStateOf solve different problems
val listState = rememberLazyListState()
// ❌ Reads the index in composition, so every index change recomposes this scope,
// and remember(key) cannot help because the key changes each time too.
val showJumpToTop = listState.firstVisibleItemIndex > 3
// ✅ Recomposes only when the Boolean actually flips
val showJumpToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 3 }
}
Use remember(keys) when inputs change rarely and the work is heavy. Use derivedStateOf when inputs change frequently but the output changes rarely. A derivedStateOf itself is normally wrapped in remember, and that particular remember is almost always justified.
Verify instead of guessing
A rule of thumb ends arguments quickly and settles edge cases poorly. For the edge cases:
- Layout Inspector recomposition counts reveal children that recompose more than you expect, which is the typical symptom of an identity problem.
-
Macrobenchmark with
FrameTimingMetrictells you whether work inside composition causes jank on a cold first frame. - Microbenchmark the block on a mid-range phone, not your daily driver, to learn whether it takes nanoseconds or microseconds.
-
Compose compiler reports (enabled through
reportsDestination) show what the compiler considers stable and skippable, which settles most callback and parameter questions.
A working checklist
- Cache heavy transformations (group, sort, map, parse, format), objects that are costly to construct, and allocations whose identity affects children.
-
Skip
rememberfor arithmetic, property reads, type and equality checks, and callbacks created inside composables under Strong Skipping. -
Hoist to a file-level
valanything with no inputs. - List every value the block reads as a key, and keep those keys immutable and cheap to compare.
-
Reach for
derivedStateOfwhen the input changes more often than the output. -
Move heavy work off the main thread, because
rememberonly caches it. -
Use
rememberSaveableor hoist state when it must outlive the call site.
remember is inexpensive, not free, and a cached value that is wrong costs far more than a recomputed one. If you cannot name what a particular remember saves, remove it and see whether anything actually gets slower.
📱 Crack Your Next Android Interview
If you’re targeting a senior Android role at a FAANG or top-tier MNC, syntax knowledge alone won’t get you through the loop. I highly recommend “Cracking Top MNC Android Interviews” — a senior-level playbook covering framework internals, Jetpack Compose, coroutines, Android-specific system design, and DSA reframed around real mobile constraints, to help you walk into bar-raiser rounds ready.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.




Top comments (0)