etpack Compose revolutionized Android UI development with its declarative paradigm, but this simplicity comes with a hidden complexity: understanding recomposition behavior. When a composable recomposes unnecessarily, you pay a performance cost, CPU cycles spent rerendering UI that didn’t actually change. The challenge isn’t just identifying these issues, it’s understanding why they happen in the first place.
The Compose compiler has always performed stability analysis behind the scenes, determining which composables can skip recomposition when their inputs haven’t changed. But this analysis happens silently at compile time, leaving developers in the dark about whether their composables are optimized or not. You might write what looks like perfectly stable code, only to discover later that a single var property in a class makes everything unstable, cascading through your entire UI tree.
Compose Stability Analyzer brings this hidden analysis into the light. It’s a comprehensive toolset that provides real-time visual feedback in your IDE, runtime recomposition tracing with annotations, and stability validation in your CI pipeline. Instead of waiting for performance problems to manifest in production, you get immediate feedback while you code, making stability analysis a natural part of your development workflow.
In this article, you’ll explore how Compose Stability Analyzer works, examining the IntelliJ plugin that brings visual stability indicators to your IDE, the compiler plugin that enables runtime recomposition tracing, and the stability validation system that prevents regressions from reaching production.
Understanding the stability problem: Why composables recompose
Before diving into the tools, it’s worth understanding what the Compose compiler actually analyzes. The compiler performs stability inference for every composable function, answering two questions:
1. Is this composable skippable?
A composable is skippable when the compiler can prove that recomposing it with the same inputs will produce the same result. If all parameters are stable and haven’t changed, the composable can skip recomposition entirely , a significant performance optimization.
2. Is this composable restartable?
A composable is restartable when it can be recomposed independently without recomposing its parent. This enables more fine-grained recomposition. Most composables are restartable, but those marked with certain modifiers might not be.
The important concept here is parameter stability. A parameter is stable when the Compose compiler can reliably detect whether its value has changed. There are such complicated rules, and if you really want to deep dive for a better understanding of them, you can refer to the research on stability, Compose Stability Inference.
Visual stability analysis in your Android Studio
The Compose Stability Analyzer IntelliJ Plugin brings stability analysis directly into Android Studio and IntelliJ IDEA, providing four layers of visual feedback as you write code.
Installation and setup
Installing the plugin is straightforward:
- Open Android Studio → Settings → Plugins → Marketplace.
- Search for “Compose Stability Analyzer”.
- Click Install and restart your IDE.

Once installed, the plugin immediately starts analyzing your code. You don’t need to configure anything , it works out of the box with any Compose project.
Gutter icons: Instant visual feedback
The most immediately visible feature is gutter icons, colored dots that appear in the left margin next to composable functions:

- Green dot: The composable is skippable. All parameters are stable, and the compiler can optimize recomposition.
- Yellow dot: The composable is restartable but not skippable. It will recompose when its parent recomposes.
- Red dot: The composable has unstable parameters that prevent optimization.
This instant visual feedback is the fastest way to spot performance issues in Jetpack Compose. A quick scan down the left margin shows you which composables need attention. If you see a screen full of red dots, you know there’s work to do.
The gutter icons are computed based on the same stability analysis that the Compose compiler performs. The plugin hooks into the compiler’s analysis phase and extracts the stability information, then renders it visually in the editor.
But you should keep in mind that, now, the Strong skipping mode is mostly enabled by default, so all your composable functions will be skippable by default. You can enable or disable the strong skipping mode check on the plugin configurations (Tools > Compose Stability Analyzer), and it’s also enabled by default since version 0.5.0. But if you want to give more attention for your stability problems, you can just check it off depending on your situation.

Hover tooltips: Understanding the why
Gutter icons tell you what is wrong, but hover tooltips tell you why. When you hover your mouse over a composable function name, a detailed tooltip appears:

The tooltip shows:
- Whether the composable is skippable and restartable
- The total count of stable vs. unstable parameters
- Detailed stability information for each parameter
- Receiver stability (for extension functions)
This granular feedback is invaluable for debugging. Instead of guessing which parameter is causing instability, you see exactly which ones are problematic.
Inline parameter hints: Granular visibility
While tooltips provide detailed information on hover, inline parameter hints give you continuous visibility without any interaction:

Small badges appear right next to each parameter’s type in the function signature, showing whether that specific parameter is stable or unstable. This is the most detailed level of feedback; every parameter’s stability is visible at a glance.
For functions with many parameters, this helps you quickly scan and identify the problematic ones without opening tooltips. The color coding matches the stability state:
- Green badge: Stable parameter
- Red badge: Unstable parameter
- Yellow badge: The stability will be decided at runtime (so it might be stable or not)
Code inspections: Automated suggestions
The fourth layer of feedback is active code inspections. When the plugin detects an unstable composable, it can:
- Highlight the issue with a warning underline.
- Suggest quick fixes via the Alt+Enter menu.
- Offer to add annotations like
@TraceRecompositionfor debugging. - Provide suppression options if the instability is intentional.

This is where the plugin becomes proactive. Instead of just showing you problems, it suggests solutions. Press Alt+Enter on an unstable composable, and you might see options like:
- Add
@TraceRecompositionto monitor recompositions. - Mark with
@Stableannotation (use with caution). - Suppress stability warning for this function.
Stability Explorer: Package-level analysis
Beyond individual function analysis, the Stability Explorer provides a bird’s-eye view of your entire codebase’s stability:

To enable it:
- Install the Compose Stability Analyzer Gradle plugin (covered in the next section).
- Go to View → Tool Windows → Compose Stability Analyzer.
- Build your project and click the refresh button.
The explorer shows a tree view of your package structure, with each composable function annotated with its stability status. You can quickly drill down to find unstable functions across your entire codebase, making it easy to prioritize optimization work.
This is particularly useful in large projects where manually reviewing every composable would be impractical. The explorer gives you a stability “report card” for your app, showing which packages have the most stability issues.
Custom Configurations
The plugin is highly customizable. You can adjust colors, enable or disable specific visual indicators, and configure analysis behavior to match your preferences.
Go to Settings → Tools → Compose Stability Analyzer to access configuration options:

You can:
- Change gutter icon colors to match your IDE theme
- Enable or disable inline hints, warnings, and gutter icons independently
- Configure Strong Skipping mode analysis
- Add ignored type patterns to exclude certain classes from analysis
- Enable analysis in test source sets
- Set a custom stability configuration file
This flexibility ensures the plugin fits naturally into your existing workflow, whether you prefer minimal visual noise or maximum information density.
The Gradle plugin: Runtime recomposition tracing
While the IntelliJ plugin provides compile-time visibility, the Gradle plugin enables runtime analysis, tracking exactly when and why your composables recompose in a running app. This is achieved through the @TraceRecomposition annotation, a compiler plugin that instruments your composables with logging code.
Installation and setup
Add the dependency below to your libs.versions.toml file:
| stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.5.0" } |
Next, apply the plugin on your root’s build.gradle.kts file like below:
alias(libs.plugins.stability.analyzer)
That’s it. The plugin automatically applies the compiler plugin to all Kotlin compilation tasks in your project. No additional configuration is required for basic usage.
Kotlin version compatibility
The plugin is tightly coupled to the Kotlin compiler, so version alignment is critical. At the moment of writing this article, you should use Kotlin 2.2.21 at least. Using mismatched versions may lead to compilation errors. Always use the exact Kotlin version that matches your Stability Analyzer version. You can check out the Kotlin Version Mapping for the references.
The @TraceRecomposition annotation
Once the plugin is installed, you can annotate any composable with @TraceRecomposition to track its recomposition behavior:
| @TraceRecomposition | |
| @Composable | |
| fun ProductCard( | |
| product: Product, | |
| onClick: () -> Unit | |
| ) { | |
| Card(onClick = onClick) { | |
| Text(product.name) | |
| Text("$${product.price}") | |
| } | |
| } |
When this composable recomposes, you’ll see detailed logs in Logcat:
D/Recomposition: [Recomposition #1] ProductCard D/Recomposition: ├─ product: Product stable (Product@abc123) D/Recomposition: └─ onClick: () -> Unit stable (Function@xyz789) D/Recomposition: [Recomposition #2] ProductCard D/Recomposition: ├─ product: Product changed (Product@abc123 → Product@def456) D/Recomposition: └─ onClick: () -> Unit stable (Function@xyz789)
The logs show:
- Recomposition count: How many times this instance has recomposed
- Parameter stability: Whether each parameter is stable or unstable
- Change detection: Which parameters changed, showing old and new values
- Identity tracking: The hashcode of each parameter value
This granular information is invaluable for debugging. You can see exactly which parameter change triggered a recomposition, helping you understand your app’s recomposition patterns.
Filtering with the tag parameter
For large apps with many traced composables, logs can become overwhelming. The tag parameter helps you organize and filter them:
| @TraceRecomposition(tag = "product-list") | |
| @Composable | |
| fun ProductCard(product: Product, onClick: () -> Unit) { | |
| // ... | |
| } | |
| @TraceRecomposition(tag = "user-profile") | |
| @Composable | |
| fun ProfileHeader(user: User) { | |
| // ... | |
| } |
Now logs include the tag:
D/Recomposition: [Recomposition #1] ProductCard (tag: product-list) D/Recomposition: [Recomposition #1] ProfileHeader (tag: user-profile)
You can filter Logcat to show only specific tags:
- Filter by
tag: product-listto see only product-related recompositions. - Filter by
tag: user-profileto see only user profile recompositions.
This is especially useful when combined with custom loggers (covered next), where you might want to send only certain tags to analytics services.
Setting a threshold to reduce noise
Most composables recompose 1–2 times during initial setup, these are expected and not performance issues. The threshold parameter filters out this noise:
| @TraceRecomposition(threshold = 3) | |
| @Composable | |
| fun FrequentlyRecomposingScreen() { | |
| // Logs will only appear after the 3rd recomposition | |
| } |
This focuses your attention on actual problems: composables that keep recomposing during user interaction, not just initial setup.
A practical use case: setting a high threshold and sending analytics events when it’s exceeded:
| @TraceRecomposition(tag = "checkout", threshold = 10) | |
| @Composable | |
| fun CheckoutScreen() { | |
| // If this recomposes 10+ times, something is wrong | |
| } |
Then in your custom logger (see next section), you can send a Firebase event when the threshold is exceeded, allowing you to monitor performance issues in production.
Configuring the logging system
By default, @TraceRecomposition doesn’t log anything, you need to enable logging in your Application class:
| class MyApp : Application() { | |
| override fun onCreate() { | |
| super.onCreate() | |
| // Enable only in debug builds | |
| ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) | |
| } | |
| } |
This is pretty important, always wrap with BuildConfig.DEBUG to avoid potential security problem in production builds, if you don’t have any custom logger.
Custom loggers: Beyond Logcat
The default logger uses Logcat, but you can provide a custom implementation to send logs anywhere you want:
ComposeStabilityAnalyzer.setLogger(object : RecompositionLogger {
override fun log(event: RecompositionEvent) {
// Send to Firebase, Crashlytics, custom analytics, etc.
if (event.recompositionCount >= 10) {
FirebaseAnalytics.getInstance(this).logEvent("excessive_recomposition") {
param("composable", event.composableName)
param("count", event.recompositionCount)
param("unstable_params", event.unstableParameters.joinToString())
}
}
}
})
So even you can set the different logger depending on the debug or release mode. The RecompositionEvent contains:
composableName: The function name.tag: The tag from@TraceRecomposition.recompositionCount: How many times it’s recomposed.parameterChanges: List of parameter changes with stability info.unstableParameters: List of unstable parameter names.
This enables powerful monitoring patterns. You can:
- Send analytics events for composables that recompose excessively
- Track performance metrics in production (with appropriate privacy safeguards)
- Build custom dashboards showing recomposition patterns
- Alert on performance regressions in staging environments
A more real-world example that uses tags to filter what gets logged:
| val tagsToLog = setOf("checkout", "product-list", "search") | |
| ComposeStabilityAnalyzer.setLogger(object : RecompositionLogger { | |
| override fun log(event: RecompositionEvent) { | |
| if (BuildConfig.DEBUG) { | |
| // In debug, log everything | |
| Log.d("Recomposition", formatEvent(event)) | |
| } else { | |
| // In release, only log tagged composables that exceed threshold | |
| if (event.tag in tagsToLog && event.recompositionCount >= 5) { | |
| // Send to analytics | |
| FirebaseAnalytics.getInstance(this).logEvent("recomposition_issue", ...) | |
| } | |
| } | |
| } | |
| }) |
Understanding the logs
Let’s walk through how to interpret the logs. Consider this example:
D/Recomposition: [Recomposition #1] UserCard D/Recomposition: ├─ user: User stable (User@abc123) D/Recomposition: └─ onClick: () -> Unit stable (Function@xyz789)
What this means:
- This is the first recomposition of this
UserCardinstance. - Both parameters are stable.
- This is expected behavior for the initial composition.
D/Recomposition: [Recomposition #2] UserCard D/Recomposition: ├─ user: User changed (User@abc123 → User@def456) D/Recomposition: └─ onClick: () -> Unit stable (Function@xyz789)
What this means:
- Second recomposition.
- The
userparameter changed (new instance). onClickremained stable (same lambda instance).- This is normal, the parameter changed, so recomposition is expected.
D/Recomposition: [Recomposition #3] UserCard (tag: user-profile) D/Recomposition: ├─ user: MutableUser unstable (MutableUser@xyz789) D/Recomposition: └─ Unstable parameters: [user]
What this means:
- Third recomposition.
- The
userparameter is unstable (likely hasvarproperties). - This composable will recompose on every parent recomposition, even if
userdidn’t change. - Action required: Fix the
MutableUserclass to be immutable.
Stability validation: Preventing regressions in CI
The most powerful feature of Compose Stability Analyzer isn’t the visual feedback or runtime tracing , it’s the ability to prevent stability regressions from reaching production. The stability validation system acts like git diff for composable stability, failing your CI build if stability degrades.
The problem: Silent performance regressions
Imagine this scenario: You’ve spent weeks optimizing your app. Every composable is stable, skippable, and fast. Then a teammate innocently changes a data class:
| // Before (stable) | |
| data class User(val name: String, val age: Int) | |
| // After (unstable) | |
| data class User(var name: String, var age: Int) |
This single change cascades through your entire UI tree. Dozens of composables that were skippable become non-skippable. Performance regresses, but the code review doesn’t catch it, there’s no visible indication that anything went wrong.
Stability validation prevents this. It tracks your composables’ stability over time and automatically fails builds when stability regresses, forcing the issue to be addressed before merging.
How it works: Stability snapshots
The validation system works through two Gradle tasks:
stabilityDump: Creates a.stabilityfile containing the stability state of all composables.stabilityCheck: Compares the current code against the baseline and fails if stability regressed.
Think of it as:
stabilityDump= “Save the current state”stabilityCheck= “Has anything changed since last save?”
Step 1: Create a baseline
After compiling your project, run:
./gradlew :app:compileDebugKotlin ./gradlew :app:stabilityDump
This generates app/stability/app.stability:
@Composable
public fun com.example.UserCard(user: com.example.User): kotlin.Unit
skippable: true
restartable: true
params:
- user: STABLE (immutable data class)
@Composable
public fun com.example.ProductList(items: kotlin.collections.List<com.example.Product>): kotlin.Unit
skippable: true
restartable: true
params:
- items: STABLE (immutable collection with stable elements)
This file is human-readable and shows exactly what the compiler determined about each composable. It’s a complete snapshot of your app’s stability state.
Commit this file to git:
git add app/stability/app.stability git commit -m "Add stability baseline" git push
Now everyone on your team has the same baseline.
Step 2: Check for regressions
In your CI pipeline, run:
./gradlew :app:compileDebugKotlin ./gradlew :app:stabilityCheck
If nothing changed, the task succeeds:
✅ Stability check passed.
If stability regressed, the task fails:
❌ Stability check failed!
The following composables have changed stability:
~ com.example.UserCard(user): parameter ‘user’ changed from STABLE to UNSTABLE
If these changes are intentional, run ‘./gradlew stabilityDump’ to update the baseline.
The build fails, preventing the pull request from being merged until the issue is fixed or the regression is explicitly accepted.
Types of changes detected
The validator detects four types of changes:

This comprehensive tracking ensures that any change to stability is visible and requires conscious decision-making.
CI/CD integration
Add stability validation to your GitHub Actions workflow:
name: Android CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Build project
run: ./gradlew :app:compileDebugKotlin
stability_check:
name: Compose Stability Check
runs-on: ubuntu-latest
needs: build
steps:
- name: Check out code
uses: actions/checkout@v5.0.0
- name: Set up JDK
uses: actions/setup-java@v5.0.0
with:
distribution: 'zulu'
java-version: 21
- name: Run stability check
run: ./gradlew stabilityCheck
Now every pull request gets automatically checked. If stability regresses, the PR cannot merge.
Configuration options
You can customize what gets tracked:
| // In your build.gradle.kts | |
| composeStabilityAnalyzer { | |
| stabilityValidation { | |
| enabled.set(true) | |
| outputDir.set(layout.projectDirectory.dir("stability")) | |
| includeTests.set(false) // Exclude test code | |
| // Ignore specific packages | |
| ignoredPackages.set(listOf("com.example.internal")) | |
| // Ignore specific classes (e.g., previews) | |
| ignoredClasses.set(listOf("PreviewComposables")) | |
| // Ignore entire modules | |
| ignoredProjects.set(listOf("benchmarks", "examples")) | |
| } | |
| } |
This is useful for excluding code that doesn’t need stability tracking, like preview composables or debug screens.
Excluding specific composables
Use @IgnoreStabilityReport to exclude individual composables from validation:
| @IgnoreStabilityReport | |
| @Preview | |
| @Composable | |
| fun UserCardPreview() { | |
| UserCard(user = User("John", 30)) | |
| } |
Preview composables aren’t in production builds, so their stability doesn’t matter. Excluding them reduces noise in your stability reports.
Multi-module projects
For projects with multiple modules, each module gets its own .stability file:
project/ ├── app/stability/app.stability ├── feature-auth/stability/feature-auth.stability └── feature-profile/stability/feature-profile.stability
Run stabilityCheck at the root to check all modules:
./gradlew stabilityCheck
Or check individual modules:
./gradlew :feature-auth:stabilityCheck
Accepting intentional regressions
Sometimes stability regressions are intentional , you’re refactoring code and temporarily accepting lower stability. When this happens, update the baseline:
./gradlew :app:stabilityDump git add app/stability/app.stability git commit -m "Accept stability regression for UserCard (refactoring in progress)" git push
This creates a documented decision in git history. The regression is no longer silent , it’s explicit and tracked.
Performance considerations and best practices
While the tools are powerful, there are performance characteristics and best practices to keep in mind. Some people have asked me whether they need to make every type stable, the answer is definitely NO.
Since the Strong skipping mode is out now each composable function is skippable, and it will optimize the stability performance already. Even though this plgin migth say, this is unstable or runtime stability, but you don’t really need to fix it for every composable function. Let’s see a simple example.

If you look into the Icon composable function from the compose-material3 library, this plugin says, it’s unstable since it contains runtime parameters. BUT, the painter parameter is mostly initialized with the painterResource function as like below:
| val painter = painterResource(R.drawable.) | |
| Icon(painter = painter, contentDescription = null) |
If you look into the internal logic of painterResource, it’s essentially stable. Internally, it uses remember to cache the value in memory , so you can think of it as a small trade-off: “I’ll use a bit of memory to store this unstable instance, so I don’t have to trigger unnecessary recompositions.”

There’s also a common misconception about immutable collections — that simply using an ImmutableList instead of a regular List will always improve performance. That’s not always true.
Think about it this way: if you have a list with 1,000 heavy objects and you switch to an immutable list, your composable becomes skippable. But on each recomposition, Compose will now run equals() on every item in that list to check if the new instance matches the previous one. In some cases, that can actually hurt performance more than just re-rendering a lightweight UI that only displays, say, five of those items.
So, stability can have very different impacts depending on the situation, and trying to make your entire UI stable is often unnecessary, and useless. My hope is that this plugin doesn’t add to your fear about stability, instead, it should help you debug real performance issues more effectively and make smarter, data-driven decisions without overthinking stability.
Job Offers
Conclusion
In this article, you’ve explored the Compose Stability Analyzer, examining how it brings visibility to Compose’s hidden stability analysis through three complementary tools: the IntelliJ plugin for visual feedback during development, the Gradle plugin for runtime recomposition tracing, and the stability validation system for preventing regressions in CI.
The IntelliJ plugin transforms stability from an invisible compile-time concept to something tangible, gutter icons, tooltips, and inline hints that make stability issues immediately visible. The @TraceRecomposition annotation bridges the gap between compile-time analysis and runtime behavior, letting you see exactly when and why composables recompose. And the stability validation system acts as a safety net, ensuring that optimizations you’ve carefully crafted don’t silently regress as your codebase evolves.
Together, these tools make stability analysis a natural part of your development workflow. You don’t need to wait for performance problems to manifest, you see them immediately as you code. You don’t need to guess which parameter is causing recomposition — the logs tell you exactly what changed. And you don’t need to manually review every PR for stability regressions, the CI pipeline does it automatically.
Understanding stability is fundamental to writing performant Compose applications. With Compose Stability Analyzer, that understanding becomes effortless, visual, immediate, and enforced.
As always, happy coding!
— Jaewoong
This article was previously published on proandroiddev.com


