A lightweight Jetpack Compose debug library that automatically detects, visualizes, and logs unnecessary recompositions in real time β without needing to open Android Studio's Layout Inspector.
β οΈ Intended for debug builds only.
Compose performance is silently killed by unnecessary recompositions. Detecting them manually is tedious and requires profiling tools. RecompositionGuard makes it automatic and always-on during development.
- π Live floating overlay β shows recomposition count per composable, updated every 100ms
- π¨ Color-coded severity β π’ OK / π‘ Moderate / π΄ Excessive
- π Logcat suggestions β tells you exactly why a composable is recomposing and how to fix it
- βοΈ Configurable thresholds β set your own warn/error limits
- πͺΆ Zero-overhead design β raw counts stored in plain
HashMap, display state flushed via coroutine every 100ms (no recomposition cascade) - π― Two tracking APIs β
Modifier.trackRecomposition()orTrackRecomposition()composable
π RecompositionGuard
βββββββββββββββββββββββββββββββ
RapidUnstableComposable [10x] π΄
RapidHotComposable [10x] π΄
RapidColdComposable [1x] π’
Logcat:
[π΄ EXCESSIVE] Composable: "RapidHotComposable" recomposed 10 time(s)
β οΈ [RapidHotComposable] recomposed 10 times. Possible causes:
-> Unstable lambda - wrap with remember { }
-> Data class missing @Stable or @Immutable annotation
-> State read inside composition - hoist it up
-> Inline function triggering parent recomposition
-> Use derivedStateOf { } for computed state
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
}dependencies {
debugImplementation 'com.github.PatilParas05:RecompositionGuard:1.0.0'
}Or in build.gradle.kts:
dependencies {
debugImplementation("com.github.PatilParas05:RecompositionGuard:1.0.0")
}π‘ Use
debugImplementationso the library is never included in release builds.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Install ONCE here β NOT inside setContent
RecompositionGuard.install(
ThresholdConfig(
warnThreshold = 3, // π‘ yellow after 3 recompositions
errorThreshold = 8, // π΄ red after 8 recompositions
overlayEnabled = true,
logsEnabled = true,
dashboardEnabled = true
)
)
setContent {
YourAppTheme {
YourRootScreen()
}
}
}
}@Composable
fun YourRootScreen() {
Box {
YourContent()
RecompositionDashboard() // floating overlay, top-right by default
}
}Option A β Modifier (recommended, attach to any composable):
@Composable
fun ProductCard(product: Product) {
Text(
text = product.name,
modifier = Modifier.trackRecomposition("ProductCard")
)
}Option B β Composable function (use inside composable body):
@Composable
fun HomeScreen() {
TrackRecomposition("HomeScreen")
// ... rest of your UI
}class RapidViewModel : ViewModel() {
private val _counter = MutableStateFlow(0)
val counter: StateFlow<Int> = _counter
fun increment() { _counter.value++ }
}
@Composable
fun RapidTestScreen(vm: RapidViewModel = viewModel()) {
val counter by vm.counter.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
while (true) {
delay(500)
vm.increment()
}
}
Box(Modifier.padding(16.dp)) {
Column(Modifier.padding(16.dp)) {
Text("Counter: $counter")
// β
Recomposes every tick β will show increasing count
HotComposable(count = counter)
// β
Never changes β stays at [1x]
ColdComposable()
// β
Unstable String parameter β recomposes every tick
UnstableComposable(value = counter.toString())
}
RecompositionDashboard(alignment = Alignment.Center)
}
}
@Composable
fun HotComposable(count: Int) {
Text(
text = "π₯ Hot: $count",
modifier = Modifier.trackRecomposition("HotComposable")
)
}
@Composable
fun ColdComposable() {
Text(
text = "βοΈ Cold: Static Content",
modifier = Modifier.trackRecomposition("ColdComposable")
)
}
@Composable
fun UnstableComposable(value: String) {
Text(
text = "β οΈ Unstable: $value",
modifier = Modifier.trackRecomposition("UnstableComposable")
)
}ThresholdConfig(
warnThreshold = 5, // recompositions before π‘ warning (default: 5)
errorThreshold = 10, // recompositions before π΄ error (default: 10)
overlayEnabled = true, // show/hide the colored border on tracked composables
logsEnabled = true, // enable/disable logcat output
dashboardEnabled = true // show/hide the floating dashboard panel
)Dashboard position:
// Top-right (default)
RecompositionDashboard()
// Center
RecompositionDashboard(alignment = Alignment.Center)
// Bottom-start
RecompositionDashboard(alignment = Alignment.BottomStart)
// Custom flush interval (default 100ms)
RecompositionDashboard(flushIntervalMs = 500L)SideEffect fires (from trackRecomposition modifier or TrackRecomposition())
β track() increments plain HashMap (rawCounts)
β NO Compose state written β NO recomposition cascade β
Every 100ms (coroutine in RecompositionDashboard)
β flush() copies rawCounts β SnapshotStateMap (data)
β Overlay recomposes once with updated counts β
The two-map pattern is the core innovation β tracking itself never triggers extra recompositions.
| API | Description |
|---|---|
RecompositionGuard.install(config) |
Initialize β call once in onCreate before setContent |
RecompositionGuard.reset() |
Reset all tracked counts |
ThresholdConfig |
Configure warn/error thresholds and feature flags |
Modifier.trackRecomposition("name") |
Track via modifier β attach to any composable |
TrackRecomposition("name") |
Track via composable function β use inside body |
RecompositionDashboard() |
Floating overlay showing live counts |
RecompositionTracker.track("name") |
Low-level tracking β use inside SideEffect { } |
RecompositionTracker.data |
SnapshotStateMap of all tracked composables |
RecompositionTracker.getCount("name") |
Get current count for a specific composable |
The tracker must be in the same recomposition scope as the state being read:
// β
CORRECT β trackRecomposition is in the same scope as `count`
@Composable
fun HotComposable(count: Int) {
Text(
text = "Hot: $count",
modifier = Modifier.trackRecomposition("HotComposable")
)
}
// β WRONG β SideEffect in a wrapper scope never fires when content recomposes
@Composable
fun GuardedComposable(name: String, content: @Composable () -> Unit) {
SideEffect { RecompositionTracker.track(name) } // This won't work reliably
Box { content() }
}Pull requests are welcome! For major changes, please open an issue first.
MIT License β Copyright (c) 2026 Paras Patil
Made with β€οΈ by Paras Patil