// Jetpack Compose · Senior Dev Guide

Stability Mastery

Practical examples to eliminate unnecessary recompositions and squeeze maximum performance from Compose.

01

What is Stability & Why It Matters

Compose decides at runtime whether to skip or re-execute a composable based on whether its parameters changed. Stability is the signal that tells Compose "it's safe to skip".

UNSTABLE Always recomposes
// ❌ interface = Unknown stability
// Compose cannot skip this
@Composable
fun UserCard(user: User) {
    Text(user.name)
    Text(user.email)
}

// Even if user didn't change,
// this ALWAYS re-executes 😱
User is an interface or has var fields
STABLE Skips when unchanged
// ✅ data class = stable (all val)
// Compose CAN skip this
@Composable
fun UserCard(user: UserData) {
    Text(user.name)
    Text(user.email)
}

// If user.equals(prev) → SKIP ✅
// Zero work done! 🚀
UserData is a stable data class
💡
Mental model: Stability = "can Compose trust equals() to detect changes?" If yes → safe to skip. If no → must re-run to be safe.
DECISION FLOWHow Compose decides to skip
Primitive? (Int, Boolean, Float...) STABLE ✓
String / Unit / Function type? STABLE ✓
Has @Stable or @Immutable annotation? STABLE ✓
Enum class or entry? STABLE ✓
In KnownStableConstructs? (ImmutableList, Pair...) STABLE ✓
In your stability config file? STABLE ✓
Interface type? UNKNOWN ⚠
Java type? UNSTABLE ✗
Has any var property? UNSTABLE ✗
All val fields with stable types? STABLE ✓
02

The 5 Stability Types

The compiler represents every type as one of these five stability categories. Knowing them helps you read compiler reports.

TypeMeaningExampleSkippable?
Certain(true) Definitely stable at compile time data class User(val id: Int) ✅ Yes
Certain(false) Definitely unstable at compile time class C(var x: Int) ❌ No
Runtime Stability depends on generic type args — checked at runtime via $stable field class Box<T>(val v: T) ⚡ Depends
Unknown Interface or abstract — can't know without implementation interface Repository ⚠️ Falls back to ===
Parameter Depends on generic type parameter T Wrapper<T> ⚡ Depends on T
RUNTIMEGeneric class → runtime check
// Compiler generates $stable field
@StabilityInferred(parameters = 0b1)
class Box<T>(val value: T)

// At usage site:
val a = Box<Int>(42)
// $stable = STABLE ✅ (Int is stable)

val b = Box<MutableList<Int>>(mutableListOf())
// $stable = UNSTABLE ❌
UNKNOWNInterface → unknown, uses ===
// Interface: compiler can't know
// which implementation will come
interface DataSource {
    fun load(): String
}

@Composable
fun Screen(src: DataSource) {
    // Falls back to reference ===
    // New lambda each recomp = bad!
}
03

The 5 Common Bugs That Kill Performance

These are the most frequent stability killers in real codebases. Each one causes unnecessary recompositions.

BUG 1 Accidental var in data class
❌ Problem
✅ Fix
// ONE var field = entire class is UNSTABLE
data class UserState(
    val name: String,
    var loading: Boolean,   // ← poison ☠️
    val email: String
)

@Composable
fun Profile(state: UserState) {
    // NEVER skipped, always re-runs
}
var → Certain(stable = false)
// All val = STABLE ✅
data class UserState(
    val name: String,
    val loading: Boolean,   // ← val ✅
    val email: String
)

// Create new instance to "update":
state = state.copy(loading = true)
// data class copy() is idiomatic ✅
All val → Certain(stable = true)
BUG 2 Mutable collections as parameters
❌ Problem
✅ Fix A — ImmutableList
✅ Fix B — Config
// List = interface = Unknown stability
// MutableList = unstable
@Composable
fun ItemList(items: List<String>) {
    // ⚠️ List is an interface → Unknown
    // Falls back to reference comparison
    // listOf() creates new reference each time!
    items.forEach { Text(it) }
}
List interface = Unknown
// ImmutableList is in KnownStableConstructs
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

@Composable
fun ItemList(items: ImmutableList<String>) {
    // ✅ Stable! Compose can skip safely
    items.forEach { Text(it) }
}

// Usage:
ItemList(persistentListOf("A", "B", "C"))
kotlinx.collections.immutable in build.gradle
// stability_config.conf:
kotlin.collections.List
kotlin.collections.Map
kotlin.collections.Set

// Then in build.gradle.kts:
composeCompiler {
    stabilityConfigurationFile =
        rootProject.layout.projectDirectory
            .file("stability_config.conf")
}

// ⚠️ Warning: you're promising these
// are stable — use carefully!
Only safe if you never mutate the list
BUG 3 Interface / abstract class parameters
❌ Problem
✅ Fix A — @Stable
✅ Fix B — Concrete
// Interface → Unknown stability
interface ViewModel {
    val uiState: UiState
    fun onAction(action: Action)
}

@Composable
fun Screen(vm: ViewModel) {
    // ⚠️ Unknown stability
    // Compose uses === for comparison
    // Same object reference? Only then skip
}
// @Stable on interface = trusted
@Stable
interface ViewModel {
    val uiState: UiState
    fun onAction(action: Action)
}

@Composable
fun Screen(vm: ViewModel) {
    // ✅ Stable — you promise
    // all implementations are stable
}
You own the contract — ensure implementations comply
// Use concrete type directly
data class UiState(
    val isLoading: Boolean,
    val items: ImmutableList<Item>
)

@Composable
fun Screen(
    uiState: UiState,         // ← stable ✅
    onAction: (Action) -> Unit  // ← lambda stable ✅
) {
    // Perfect — skippable! ✅
}
BUG 4 Third-party library types
❌ Problem
✅ Fix
// Third-party class — no @StabilityInferred
// → Unstable by default
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class ApiResponse(
    val id: Int,
    val title: String
)

@Composable
fun Card(response: ApiResponse) {
    // ❌ Marked unstable even though
    // it's all-val, because it's from
    // an external module
}
// Option A: stability_config.conf
com.squareup.moshi.JsonClass

// Option B: wrapper data class
data class CardModel(
    val id: Int,
    val title: String
)

// Map in ViewModel, not in UI:
fun ApiResponse.toCardModel() =
    CardModel(id = id, title = title)

@Composable
fun Card(model: CardModel) {
    // ✅ Fully stable, skippable
}
Wrapper pattern: map 3rd-party → your own stable models
BUG 5 Unstable superclass / inheritance
❌ Problem
✅ Fix
// Unstable base poisons ALL subclasses
open class BaseModel(
    var id: Int   // ← var in base = death 💀
)

class UserModel(
    val name: String
) : BaseModel(0)

// UserModel: fields all-val? Doesn't matter.
// Superclass has var → UserModel is UNSTABLE
// Fix: Make base class immutable
open class BaseModel(
    val id: Int   // ← val ✅
)

data class UserModel(
    val name: String,
    val baseId: Int   // prefer composition
)
// Better: flatten hierarchy entirely
// Inheritance in UiState = common mistake
04

Stability Annotations

@ImmutableTruly frozen — strongest guarantee
// Contract: nothing changes EVER
@Immutable
data class Theme(
    val primary: Color,
    val background: Color
)

// Extra benefit: constructor calls
// become "static expressions"
// → Better lambda memoization
val lambda = { theme.primary }
// ↑ Compiler can optimize this more
// aggressively with @Immutable
Use for: config objects, themes, constants
@StableControlled mutability — flexible
// Contract:
// 1. equals() works correctly
// 2. State changes trigger recomposition
@Stable
class CartState {
    private var _items by mutableStateOf(listOf<Item>())
    val items: List<Item> get() = _items

    fun add(item: Item) {
        _items = _items + item
        // mutableStateOf auto-triggers
        // recomposition ✅
    }
}
Use for: state holders, observable classes
@StableMarkerCreate your own stability annotation
// Define once in your project:
@StableMarker
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
annotation class UiModel

// Use across your codebase:
@UiModel
data class ProductCard(
    val id: String,
    val title: String,
    val price: Double
)

@UiModel
data class OrderSummary(
    val orderId: String,
    val total: Double
)
// Both treated as @Stable ✅
Great pattern for large teams — semantic + performance in one annotation
⚠️
Key difference: @Immutable enables extra compile-time optimization where constructor calls become static expressions (better lambda memoization). @Stable allows private mutability but doesn't get this bonus. When in doubt on a pure data class: use @Immutable.
05

Stability Configuration File

The most powerful tool for fixing stability on third-party types you don't control. One file, zero code changes to your models.

📄 stability_config.conf
// ── Exact class ──────────────────
com.google.gson.JsonObject

// ── Entire package (recursive) ───
com.example.models.**

// ── Single-level wildcard ─────────
com.example.*.dto

// ── Generic: * = matters, _ = skip
kotlin.collections.List            // no generics
com.example.Container<*>           // T matters
com.example.Either<*,*>            // both matter
com.example.Cached<*,_>            // only first
com.example.Complex<*,_,*>         // 1st and 3rd
🔧 build.gradle.kts
plugins {
    id("org.jetbrains.kotlin.plugin.compose")
}

composeCompiler {
    stabilityConfigurationFile =
        rootProject.layout
            .projectDirectory
            .file("stability_config.conf")

    // Optional: enable reports
    reportsDestination =
        layout.buildDirectory
            .dir("compose_reports")
    metricsDestination =
        layout.buildDirectory
            .dir("compose_metrics")
}
PATTERN GUIDEUnderstanding generic parameter syntax
// * = this type param's stability MATTERS
// _ = ignore this type param

// Pair<A,B> — both A and B matter:
kotlin.Pair<*,*>
// If Pair<String, MutableList> → UNSTABLE (MutableList is *)
// If Pair<String, Int> → STABLE

// Result<T> — T matters:
kotlin.Result<*>

// Hypothetical cache where key type is irrelevant:
com.example.Cache<_,*>
// Only value type (2nd param) affects stability
// Cache<Any, StableData> → STABLE (key ignored)
Bitmask under the hood: 0b1 = first param, 0b10 = second, 0b11 = both
06

Compiler Reports — Find What's Broken

Enable reports to see exactly which composables are skippable and why your classes are unstable. This is your performance X-ray.

🔧 Enable in build.gradle.kts
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
// Then: ./gradlew assembleRelease
// Find reports at: build/compose_reports/
FILE 1module-classes.txt
// What you WANT to see:
stable class User {
  stable val id: Int
  stable val name: String
}

// What you DON'T want:
unstable class Counter {
  unstable var count: Int
}

// Generic — OK, depends on T:
runtime stable class Box<T> {
  stable val value: T
}
FILE 2module-composables.txt
// GOOD — skippable ✅
restartable skippable
fun UserCard(
  stable user: User
)

// BAD — can't skip ❌
restartable
fun CounterCard(
  unstable counter: Counter
)
// ↑ Find these and fix them!
// Especially in LazyColumn items
FILE 3module-module.json
{
  "skippableComposables": 45,
  "restartableComposables": 50,
  "readonlyComposables": 5,
  "totalComposables": 100
}

// 🎯 Your goal:
// skippable / restartable → 90%+
// Focus on screens, list items,
// and frequently-recomposed nodes
🎯
Pro tip: Prioritize fixing composables inside LazyColumn / LazyRow item slots. Each list item being unskippable means every scroll re-runs all visible items. This is where stability bugs have the most visible FPS impact.
07

Advanced Patterns

ADVANCEDDelegated var — the exception to the var rule
// Delegated var is NOT automatically unstable!
// The compiler checks the delegate type instead.

@Stable
class FormState {
    // ✅ var delegated to MutableState
    // Compose tracks this automatically
    var username: String by mutableStateOf("")
    var password: String by mutableStateOf("")
    var isValid: Boolean by mutableStateOf(false)
}

// This is stable because:
// 1. Annotated with @Stable
// 2. All mutations go through mutableStateOf
// 3. Compose will be notified of changes
var delegated to mutableStateOf = safe
FREEEnums are always stable
// Enums = always Certain(true)
// No annotation needed!
enum class LoadState {
    IDLE, LOADING, SUCCESS, ERROR
}

@Composable
fun StatusBadge(state: LoadState) {
    // ✅ Fully skippable, free!
    when(state) {
        LoadState.LOADING -> Spinner()
        LoadState.ERROR -> ErrorView()
        else -> {}
    }
}
FREEProtobuf — auto stable
// Generated proto classes that extend:
// GeneratedMessageLite or GeneratedMessage
// → Compiler marks as stable ✅

// No config needed, works out of box
@Composable
fun ProtoCard(msg: UserProto) {
    // ✅ Stable if UserProto extends
    // GeneratedMessageLite (it does)
    Text(msg.name)
}
ADVANCEDInline / value classes — stability flows through
// Stability of value class = stability of wrapped type
@JvmInline
value class UserId(val value: Int)
// ✅ Stable — Int is stable

@JvmInline
value class Tag(val value: String)
// ✅ Stable — String is stable

// Override with @Stable when underlying type is unstable:
@JvmInline
@Stable
value class StableWrapper(val list: MutableList<Int>)
// ✅ @Stable overrides MutableList's instability
// ⚠️ YOU are responsible for the contract!
Value classes are a great tool to add type safety AND maintain stability
GOTCHARecursive / self-referential types → always unstable
// ❌ Recursive type = compiler cycle detection
// → Returns Unstable conservatively
data class TreeNode(
    val value: Int,
    val left: TreeNode?,   // ← cycle!
    val right: TreeNode?  // ← cycle!
)
// Even though logically stable, compiler marks UNSTABLE

// Workaround: force-annotate
@Immutable
data class TreeNode(
    val value: Int,
    val left: TreeNode?,
    val right: TreeNode?
)
// ✅ Annotation overrides cycle detection
Limitation of the inference algorithm — override manually when safe
08

Senior Dev Cheat Sheet

QUICK REFStability at a glance
Type / PatternStabilityAction Needed
Int, Boolean, String, lambdas✅ StableNone
enum class✅ StableNone
data class all-val, stable fields✅ StableNone
data class with any var❌ UnstableChange to val, use copy()
List<T>, Map, Set (interfaces)⚠️ UnknownUse ImmutableList or add to config
interface parameter⚠️ UnknownAdd @Stable or use concrete type
abstract class⚠️ UnknownAdd @Stable if you control it
Java type❌ UnstableWrap in Kotlin data class
3rd-party class❌ UnstableWrap or add to stability_config.conf
Generic Box<T>⚡ RuntimeStable when T is stable
Class inheriting unstable base❌ UnstableFix base or flatten hierarchy
var delegated to mutableStateOf✅ OKAdd @Stable to class
Protobuf generated class✅ StableNone — auto-detected
Recursive / self-ref type❌ UnstableAdd @Immutable if truly immutable
ACTION PLANPerformance audit workflow
  • 1
    Enable compiler reports in build.gradle.kts → run ./gradlew assembleRelease
  • 2
    Open build/compose_reports/module-composables.txt → search for restartable WITHOUT skippable
  • 3
    Check module-classes.txt for unstable classes used in those composables
  • 4
    Fix top offenders: varval, ListImmutableList, interfaces → @Stable
  • 5
    Add 3rd-party types to stability_config.conf, wire it in Gradle
  • 6
    Re-run reports — verify skippable count improves
  • 7
    Prioritize: LazyColumn items → screen-level composables → inner composables
  • 8
    Use Layout Inspector → Recomposition Counts to confirm impact at runtime