Your Compose app recomposes 10 times a second. Is that a problem?

The Compose ecosystem has solid tooling for tracking recompositions. Layout Inspector shows counts and skip rates. Compiler reports surface stability issues. Rebugger logs argument changes. ComposeInvestigator traces recomposition causes automatically. Each of these tools answers an important question well.

But none of them answer this one:

"Is this composable recomposing too much for what it does?"

A HomeScreen recomposing 10/s is a problem. A gesture-driven animation recomposing 10/s is fine. The number is the same. The answer is completely different. Without knowing the composable's role, you can't tell which is which.

Here's what Rebound showed me in a production app last month:

Composable Rate Budget Skip% Status
ShimmerBox 18/s 5/s 0% OVER
MenuItem 13/s 30/s 0% NEAR
DestinationItem 9/s 5/s 0% OVER
AppScaffold 2/s 3/s 68% OK

ShimmerBox at 18/s is a fire. AppScaffold at 2/s is fine. MenuItem at 13/s has headroom because it's interactive. Same app, same moment, completely different answers depending on what each composable is supposed to do.

No existing tool gives you that column: Budget.


The flat threshold trap

Every recomposition monitoring approach I've seen does the same thing: pick a number, flag everything above it. 5/s, 10/s, whatever feels right.

This is wrong for most of your composables.

Set it at 5/s and your animations light's up red all day. Set it at 60/s, and your screen-level state leak never gets caught. You end up ignoring the warnings entirely, which is worse than not having them.

I kept tuning thresholds per project, per screen, per interaction. Then I realized: the composables' role should determine the threshold. A Screen has a different budget than a LazyColumn item has a different budget than an animate* call. The compiler already knows which is which.

What already exists

Here's the current landscape. Each tool answers a different question — and leaves a different gap:

Compose recomposition tool landscape — analysis depth vs developer effort

The ecosystem already has good answers for individual questions. Compiler Reports tell you what's skippable. Layout Inspector shows recomposition counts and, since 1.10.0, which state reads triggered them. Rebugger logs argument diffs. ComposeInvestigator traces recomposition causes automatically. VKompose highlights hot composables with colored borders. Perfetto gives you the full rendering pipeline.

I use most of these. But they all share the same blind spot: a count of 847 means nothing without knowing what the composable does. None of them answer "is this rate acceptable for this composable's role?"

From principles to practice

The Compose team's guidance is principle-based: minimize unnecessary recompositions, use stable types, hoist state. This is the right approach. Ben Trengrove's articles on stability and debugging recomposition, Leland Richardson's deep dives into the compiler and runtime — they all reinforce the same idea: make parameters stable and the compiler handles the rest. @Stable, @Immutable, compiler reports, Strong Skipping Mode (default since Kotlin 2.0.20) — the framework gives you the tools to get structural correctness right.

Where it gets harder is triage. Your types are stable, compiler metrics look clean, but a screen still janks. Layout Inspector shows a recomposition count of 847 on a composable. Is that a lot? Depends entirely on what that composable does — and nothing in the current tooling connects the count to the context.

The natural instinct is to set a flat threshold. Pick a number — say 10 recompositions per second — and flag anything above it. I've tried this. It falls apart fast:

  • An animated composable at 12/s gets flagged. It shouldn't.
  • A screen composable at 8/s passes. It shouldn't.
  • A list item at 40/s during fast scroll looks alarming. That's expected.

You either raise the threshold until the false positives go away (and miss real issues) or lower it until real issues surface (and drown in noise). Any single number you pick is wrong for most of your composables.

Budget depends on what the composable does

A screen composable has a different recomposition budget than an animation-driven one. A leaf Text() with no children has a different budget than a LazyColumn recycling items during scroll. This seems obvious in hindsight.

Recomposition budgets by composable role

Match the budget to the role and the useful warnings stop hiding behind false ones.

Rebound's compiler plugin classifies every @Composable at the IR level:

  • Screen (3/s) — name contains Screen or Page. If this recomposes more than 3 times a second, state is leaking upward.
  • Leaf (5/s) — no child @Composable calls. Text, Icon, Image. Individually cheap but shouldn't thrash.
  • Animated (120/s) — calls animate*, Transition, or Animatable APIs. Give it room to run at 60-120fps without false alarms.

There are six classes total (Container at 10/s, Interactive at 30/s, List Item at 60/s round out the set), but those three tell the story. The point is: a single threshold cannot be correct for all of them simultaneously.

During scrolling, budgets double. During animation, they go up 1.5x. During user input, 1.5x. The system knows context and adjusts.

The 0% skip rate discovery

Here's the moment that convinced me this approach works.

I was running Rebound on a travel app. The Hot Spots tab flagged TravelGuideCard at 8/s against a LEAF budget of 5/s. That alone is useful but not surprising — cards in a list recompose during scroll.

The interesting part was the skip rate: 0%.

Zero percent means every single recomposition did actual work. The Compose runtime never skipped it. That's unusual for a card component — most of the time, at least some recompositions should skip because the inputs haven't changed.

I pulled up the Stability tab. The $changed bitmask showed the destinations parameter was DIFFERENT on every frame. But the data wasn't changing — the list was the same destinations, same order, same content.

Traced it back to the data layer. A helper function was calling listOf(...) on every invocation instead of caching the result. Every call created a new List instance. Same content, new reference. Compose saw a different object and recomposed.

One remember {} block. Skip rate went from 0% to 89%. Rate dropped from 8/s to under 1/s.

Layout Inspector would have told me "8 recompositions per second." It would not have told me that 8/s is over budget for a leaf, that the skip rate was zero, or that a single parameter was DIFFERENT every frame. I would have shrugged and moved on.

What I actually found

I tested this on an app with draggable elements, physics animations, and sensor-driven UI. 29 composables instrumented, zero config.

A gesture-driven composable at 13/s? ANIMATED budget is 120/s. Fine. A flat threshold of 10 would've flagged this on every drag.

A remember-based state holder at 11/s? LEAF budget is 5/s. Real violation. A sensor was pushing continuous updates into recompositions. Two-line fix: debounce the input. I would've missed this with flat thresholds because I was busy dismissing animation warnings.

The interaction context matters too. Rebound detects whether the app is in IDLE, SCROLLING, ANIMATING, or USER_INPUT state. A list item at 40/s during scroll is expected — the same rate during idle is a problem. Same composable, same number, different verdict.

Solving the <anonymous> problem

Compose uses lambdas everywhere. Scaffold, NavHost, Column, Row, LazyColumn — all take lambdas. Every one of those lambdas is a @Composable function that gets instrumented. When you inspect the IR, you get back names like:

com.example.HomeScreen.<anonymous>
com.example.ComposableSingletons$MainActivityKt.lambda-3.<anonymous>

The tree is 80% <anonymous>. You're staring at a recomposition violation and you have no idea if it's the Scaffold content, the NavHost builder, or a Column's children.

Layout Inspector doesn't have this problem. It reads sourceInformation() strings from the slot table — compact tags the Compose compiler injects into every composable call. The name is right there. Layout Inspector reads it. Nothing else does.

Rebound takes a different approach: resolve names at compile time in the IR transformer. When the transformer visits an anonymous composable lambda, it walks the function body, finds the first user-visible @Composable call that isn't a runtime internal, and uses that call's name as the key.

Lambda name resolution — before and after

A lambda whose body calls Scaffold(...) becomes HomeScreen.Scaffold{}. A lambda that calls Column(...) becomes ExerciseCard.Column{}. The {} suffix distinguishes a content lambda from the composable function itself.

private fun resolveComposableKey(function: IrFunction): String {
    val raw = function.kotlinFqName.asString()
    if (!raw.contains("<anonymous>")) return raw

    val pkg = extractPackage(raw)
    val parentName = findEnclosingName(function)
    val primaryCall = findPrimaryComposableCall(function)

    if (primaryCall != null) {
        return "$pkg$parentName.$primaryCall{}"
    }
    // fallback to counter-based λN
    ...
}

So com.example.HomeScreen.Scaffold{} displays as HomeScreen.Scaffold{} in the tree instead of <anonymous>.

Reading the $changed bitmask

The Compose compiler injects $changed parameters into every @Composable function. Each parameter gets 2 bits encoding its stability state.

Decoding the $changed bitmask — 2 bits per parameter

Rebound collects these at compile time and decodes them at runtime: bits 01 mean SAME, 10 mean DIFFERENT, 11 mean STATIC, 00 mean UNCERTAIN. When a composable recomposes with a parameter marked DIFFERENT, you know exactly which argument the caller changed.

Rebound goes further — it separates forced recompositions (parent invalidated) from parameter-driven ones. When a violation fires, you see both: which parameters changed and whether the recomposition was forced by a parent or triggered by the composable's own state.

Introducing Rebound

Rebound — Compose Recomposition Budget Monitor
Budget-based recomposition monitoring for Jetpack Compose. A Screen at 3/s. An Animation at 120/s. Zero config. Debug builds only.

Rebound is a Kotlin compiler plugin and an Android Studio plugin. Here's how the pieces connect:

Rebound architecture — compile time to runtime to IDE

The compiler plugin runs after the Compose compiler in the IR pipeline: it classifies each composable into a budget based on name patterns and call tree structure, resolves human-readable keys for anonymous lambdas, and injects tracking calls. At runtime, it monitors recomposition rates against those budgets. The IDE plugin connects over a socket — not logcat — so you get structured data instead of string-parsed log lines.

When something exceeds its budget:

BUDGET VIOLATION: ProfileHeader rate=11/s exceeds LEAF budget=5/s
  -> params: avatarUrl=CHANGED, displayName=CHANGED
  -> forced: 0 | param-driven: 11 | interaction: IDLE

The composable name. The rate. The budget. The parameters that changed. Whether it was forced by a parent or driven by its own state. What the user was doing at the time.

Here's Rebound running on StickerExplode — an app with draggable stickers, tilt-sensor physics, and haptic feedback. The tilt sensor pushes continuous updates, so rememberTiltState, rememberTiltSensorProvider, and rememberHapticFeedback all recompose at 7–17/s. Their default LEAF budget is 5/s, so Rebound flags them.

But that's the point — these composables are sensor-driven. They should recompose frequently. The violations aren't saying the code is broken. They're saying the classification needs tuning: LEAF → INTERACTIVE (30/s budget). The budget system surfaces the mismatch. You adjust the role, the noise disappears, and the real problems stay visible.

The sparkline at the bottom shows the rate history. The event log timestamps every violation. Double-click any row in Hot Spots and it jumps to the source.

Zero config. Debug builds only, no overhead in release. Three lines in your build file. KMP — Android, JVM, iOS, Wasm.

The IDE plugin: a Compose performance cockpit

The first version of the IDE plugin was a tree with numbers. Useful, but you still had to do most of the interpretation yourself. v2 is a full-performance cockpit.

Rebound IDE Plugin — 5 tabs, gutter icons, event log

Monitor tab — The live composable tree, now with sparkline rate history per composable and a scrolling event log. Violations, rate spikes, state transitions — all timestamped. This was the entire plugin before. Now it's tab 1.

Hot Spots tab — A flat, sortable table of every composable. Sort by rate, budget ratio, skip percentage. Summary card at the top: "3 violations | 12 near budget | 85 OK." Double-click any row and it jumps to the source file. Like a profiler's method list, but for recompositions.

Timeline tab — A composable-by-time heatmap. Green, yellow, red cells. Scroll back 60 minutes. You can see temporal patterns: "UserList was hot for 5 seconds during scroll, then calmed down." Helps separate one-off spikes from sustained problems.

Gutter icons — Red, yellow, green dots next to every @Composable function in the editor. Click for rate, budget, and skip percentage. No tool window switching needed. This is the single most impactful UX change — the research on developer tooling is clear that context-switching between a profiler window and source code is where time goes to die.

We had stable data in prod for months. Then a feature change made one of our lists unstable. We shipped it without catching it. Rebound would have caught it locally — a gutter icon going from green to red the moment the change was made.

Git history to track regressions.
Visualize the Hotspots
Monitor in real time each composition
Stability checks
Timeline view of how much app is recomposing

Productionize

I tested this on a CMP app with a messy home screen. LazyRows nested inside a LazyColumn, animated list items, async images. 29 composables instrumented, zero config.

A card component was recomposing 8 times with a 0% skip rate, peaking at 8/s. The whole tree went together: Column, Image, Text, painterResource. Rebound traced it to a MutableIntState layout measurement change cascading through. Turned out a helper function was creating a new List<> on every call. The contents were static but the container was a fresh allocation, so Strong Skipping couldn't help. One remember {} fixed it.

A destination item had the same shape of problem. 10 compositions, 0% skip. Rebound flagged destination=UNCERTAIN, paramType=unstable because the data class was passed inline without @Stable.

Layout Inspector would have shown me "this composable recomposed 10 times." What it can't tell me is whether 10 is a problem. For a LEAF composable with a 5/s budget and a 0% skip rate, it absolutely is.

Try it

 // build.gradle.kts
 plugins {
     id("io.github.aldefy.rebound") version "0.2.1"
 }

Add the Gradle plugin, build in debug, and see which of your composables are over budget. Works on Kotlin 2.0 through 2.3, Android and iOS. The budget numbers come from testing across several Compose apps — if your app has different composition patterns and the defaults don't fit, open an issue. That's how the numbers get better.

The sample module has Rebound pre-configured. For a real stress test, StickerExplode is a particle-effect demo that exercises every budget class.

Source, docs, and CLI: github.com/aldefy/compose-rebound

If your AI coding tool supports skills, the rebound-skill repo teaches it how to diagnose violations. Works with Claude Code, Gemini CLI, Cursor, Copilot, and others.


@AditLal on X / aldefy on GitHub