A closer look at parameter-state encoding, compiler IR analysis, comparison propagation, SlotTable storage, and the optimizations that happen before recomposition decisions reach the runtime.

Jetpack Compose doesn’t need to compare every argument passed to every composable function.

The compiler already knows that some arguments cannot change. Consider a simple composable:

@Composable
fun ProductCard(
title: String,
isAvailable: Boolean
) {
ProductCardContent(
title = title,
isAvailable = isAvailable,
maxLines = 2,
showDivider = false
)
}

Four arguments are passed to ProductCardContent, but they don't all have the same characteristics.

title and isAvailable originate from the parent composable's parameters. Their change status can potentially be inherited from the parent. maxLines and showDivider, on the other hand, are fixed expressions.

There is no reason for the child composable to compare 2 against a previously stored 2, or false against a previously stored false.

These expressions can be identified at compile-time by the Compose compiler and stored in a synthetic parameter of type integer called $changed.

This means that the compiler doesn’t just pass argument values. It passes information about those values.

Understanding how this works will show us two very similar optimizations.

Static argument elimination and comparison propagation.

Both of which reduce the number of comparisons performed at runtime and the amount of storage space required in the SlotTable for detecting parameter changes.

The implementation details below are based on the AndroidX Compose compiler source. The generated signatures and bit structure are an internal part of the compiler and therefore can vary between releases.

1. Why Compose needs a parameter change-tracking mechanism

A composable function is not an ordinary function call from the runtime’s perspective.

Consider:

@Composable
fun UserProfile(name: String) {
Text(text = name)
}

The compiler transforms composable functions to communicate with the Compose runtime. Among other things, it introduces synthetic parameters such as:

  • A Composer reference.
  • A $changed bitmask describing the state of the arguments.
  • Additional changed masks when the parameter count requires them.
  • Default-argument metadata where applicable.

Conceptually, the transformed function resembles:

fun UserProfile(
name: String,
$composer: Composer<*>,
$changed: Int
)

This is illustrative pseudocode, not the exact generated JVM signature.

The runtime will need to know if the parameters of the function have changed since last run. If this information is not available from the caller then the generated code can call Composer.changed() to compare the current value with the previous stored value.

That comparison requires storage in the SlotTable.

The SlotTable stores all information related to the composition including previous values used by the runtime (e.g. parameter comparison and remember).

The crucial observation is that this comparison is only necessary when the compiler and caller don’t already know the answer.

This is precisely what $changed communicates.

We can look at the implementation in the ComposableFunctionBodyTransformer in the compiler. This handles the composable skipping, default arguments and comparison propagation.

2. Anatomy of the $changed bit field

The $changed parameter is an integer bitmask. It packs multiple pieces of information about the composable's parameters.

In the implementation examined, the compiler uses:

const val BITS_PER_INT = 31
const val SLOTS_PER_INT = 10
const val BITS_PER_SLOT = 3

Why 31 bits instead of 32?

The lowest bit is used to represent a function level flag which can be used to force execution. The next 30 bits are used to store ten 3-bit values representing parameters.

The effective layout is:

32-bit integer, Bit 0: force execution, Bit 31: unused

More precisely:

The compiler calculates a parameter’s offset using:

fun bitsForSlot(bits: Int, slot: Int): Int {
val realSlot = slot % SLOTS_PER_INT
return bits shl (realSlot * BITS_PER_SLOT + 1)
}

A function with more than ten tracked parameter slots requires additional changed-mask integers.

Three bits of data are allocated to each parameter. However only 2 bits are used to indicate the comparison state.

The remaining bit carries stability metadata.

The two-bit comparison state

The comparison states are:

The third bit is used to represent instability where required:

The compiler’s StabilityBits implementation defines:

UNSTABLE = 0b100
STABLE = 0b000

Consequently, Static is encoded as 0b011 within the three-bit slot, with the stable marker remaining clear.

For parameter slot zero, the actual shifted value is:

Static = 0b011 shl 1
= 0b00000110

This compact representation allows the compiler to communicate both comparison state and stability information without introducing an object for every parameter.

3. How the compiler determines that an argument is static

The optimization begins before runtime execution, it happens at compile time.

The Compose compiler works with Kotlin Intermediate Representation (IR), and during its lowering passes, it examines the expressions being passed to composable functions, collects metadata about the arguments.

The relevant implementation uses structures named CallArgumentMeta and ParamMeta.

Conceptually, CallArgumentMeta records:

data class CallArgumentMeta(
var stability: Stability,
var isVararg: Boolean,
var isProvided: Boolean,
var isStatic: Boolean,
var paramRef: ParamMeta?
)

This is a simplified representation of the compiler’s actual implementation.

The compiler examines the argument expression and determines:

  1. Whether its value can be established as static.
  2. Whether it originates from an enclosing composable parameter.
  3. Whether its stability is known.
  4. Whether it was explicitly provided or comes from a default expression.
  5. Whether the argument is a vararg.

The static-expression analysis is implemented in AbstractComposeLowering.kt, particularly through IrExpression.isStatic().

Expressions the compiler can classify as static

The implementation recognizes several categories.

Compile-time constants

Text(
text = "Hello",
maxLines = 2
)

IR constant expressions can be classified as static.

Enum values

alignment = AlignmentOption.Center

Enum entries are treated as static.

Immutable local variables

val maxLines = 2

Text(
text = title,
maxLines = maxLines
)

An immutable local variable can inherit static classification from its initializer.

A mutable variable doesn’t receive the same treatment.

Stable object and companion-object references

The compiler can classify object references as static when the relevant stability conditions are satisfied. Companion-object references receive special handling.

Stable property access

Certain read-only properties with default getters and stable return types can be classified as static, provided their receivers are also static. Stable annotations can also participate in this analysis.

Operations on static values

The compiler recognizes selected Kotlin operations, including arithmetic and Boolean operations, when the operation, result type, and operands satisfy its staticness conditions.

For example, a computation involving only constant operands can be classified as static.

The compiler also has specialized handling for certain function calls, including eligible stable functions with stable return types and static arguments.

This is not general-purpose constant folding. The compiler uses specific structural and stability rules to establish that an expression is safe to treat as static.

A dynamic state read remains dynamic.

val currentMaxLines = uiState.value.maxLines

Text(
text = title,
maxLines = currentMaxLines
)

The fact that currentMaxLines is declared using val doesn't make it static. Its initializer depends on observable runtime state.

This is an important distinction: immutability of a local reference is not proof that its value is constant.

4. Comparison propagation: avoiding repeated work across composable calls

Static argument elimination is only half of the optimization.

The compiler also propagates parameter comparison results through the composable call graph.

Return to the earlier example:

@Composable
fun ProductCard(
title: String,
isAvailable: Boolean
) {
ProductCardContent(
title = title,
isAvailable = isAvailable,
maxLines = 2,
showDivider = false
)
}

The compiler can identify that title and isAvailable directly reference parameters of ProductCard.

It doesn’t need to make ProductCardContent independently compare those values if ProductCard already knows their change status.

The ParamMeta structure contains the parent parameter's slot index and a reference to the relevant changed or dirty mask.

When constructing the child’s changed mask, the compiler can extract the parent’s parameter state and move it into the child’s corresponding slot.

The logic is effectively:

The actual implementation is more involved because it handles slot offsets, multiple masks, stability metadata, default arguments, and different expression types.

The important operation is in buildChangedArgumentsForCall().

For each argument, the compiler decides whether to:

  • Set a static state directly.
  • Propagate a known parameter state from the parent.
  • Mark the argument uncertain.
  • Handle varargs or omitted defaults through their specialized paths.

The resulting child mask is assembled using constants, bitwise operations, and shifts.

This prevents re-doing the same comparison at each level of a composable hierarchy.

We can split these into two useful categories:

  • Static classification: The compiler can prove that an expression will never change.
  • Comparison propagation: The compiler propagates a comparison result (which has been proven) from one composable to another.

Both reduce the number of comparisons, but use different information.

5. What happens inside the composable function

The receiving composable uses the incoming $changed mask to decide whether any parameter comparisons remain necessary.

Consider the conceptual logic:

if (parameterState == Uncertain) {
parameterState =
if (composer.changed(value)) {
Different
} else {
Same
}
}

This is simplified pseudocode, not literal generated source.

The important detail is that Composer.changed() is called only when the incoming state requires a comparison.

When called, the runtime compares the current value against its previously recorded value and updates the SlotTable bookkeeping.

The resulting state is reflected in the local $dirty mask.

Three cases have different consequences:

Uncertain: A comparison is required. The runtime needs the previous value to establish whether the parameter changed.

Same or Different: The caller already performed the necessary comparison. The callee can use the propagated result without performing another comparison or retaining a duplicate previous value for this purpose.

Static: No comparison is necessary at all. The compiler has established that the argument cannot change.

This is why the optimization saves more than the cost of comparing two integers or references. It can also eliminate the parameter-specific SlotTable entry used solely for change detection.

The distinction is documented in the compiler source comments: the uncertain state is the one that can require SlotTable space for the comparison.

6. Static default arguments are another optimization opportunity

Composable functions frequently use default parameters:

@Composable
fun ProductTitle(
title: String,
maxLines: Int = 2,
color: Color = LocalContentColor.current
) {
Text(
text = title,
maxLines = maxLines,
color = color
)
}

The compiler handles default values for composable functions with the use of a separate $default bitmask.

This mask must not be confused with $changed.

  • The $default bitmask will tell you which arguments were not provided.
  • The $changed bitmask tells you about the parameter comparison and stability.

The default mask has one bit per parameter to indicate if the default expression needs to be evaluated.

The compiler can also optimize the default expression.

For maxLines = 2, the compiler knows the default is static. If the caller omits the argument, the resulting parameter can be assigned the Static state.

For color = LocalContentColor.current, the result depends on the composition environment. It cannot simply be treated as an immutable constant.

The compiler must preserve the behavior of dynamic defaults, including the possibility that they contain composable calls or observable state reads.

This also affects generated composition groups. When default expressions require runtime evaluation, the compiler may generate a defaults group to ensure that they are reevaluated correctly when necessary. When all defaults are static, it can avoid some of that machinery.

This is a useful example of how a seemingly minor expression-level optimization can influence the generated composition structure.

7. Static does not mean stable, and neither guarantees skipping

There are three concepts that need to remain separate.

Static describes an argument expression that cannot change during program execution.

Stable describes whether Compose can reliably determine changes to an object’s observable properties.

Skippable describes whether a composable can avoid executing its body when called during recomposition and its relevant inputs remain unchanged.

For example:

@Composable
fun UserName(name: String) {
Text(
text = name,
maxLines = 1
)
}

String is a stable type, but name can change between compositions.

maxLines is static because it is fixed.

The composable’s ability to skip execution is a separate decision involving its other parameters, restartability, invalidations, and runtime state.

Strong skippingis an extra dimension. Enabled by default from Kotlin 2.0.20 onwards. If a composable is restartable, it can be skipped even if it contains unstable parameters. Parameters will be compared using structural equality if stable, or instance equality if strong skipping is enabled.

Static arguments are another optimisation. The compiler does not need to compare parameters since it can confirm the argument will never change.

This does not stop recompositions happening due to a State being read within the composable, or the CompositionLocal changing or some other invalidation.

The optimization concerns parameter change detection, not every possible reason a composable might execute.

8. How to inspect static argument optimization in your project

The compiler exposes reports and metrics that help investigate how it analyzes composables.

For Kotlin projects using the Compose compiler Gradle plugin, enable reports and metrics:

composeCompiler {
reportsDestination =
layout.buildDirectory.dir("compose_compiler")

metricsDestination =
layout.buildDirectory.dir("compose_compiler")
}

The generated reports include several useful artifacts.

module.json: Contains aggregate metrics such as:

{
"staticArguments": 25,
"certainArguments": 138,
"knownStableArguments": 377,
"knownUnstableArguments": 25,
"totalArguments": 426
}

These values illustrate the types of metrics available. They are not measurements from a particular application.

staticArguments counts arguments identified as static at composable call sites.

certainArguments provides information about arguments for which the compiler can propagate comparison metadata.

composables.txt: Provides composable signatures with parameter stability and default-expression classifications.

For example, the output can distinguish:

stable maxLines: Int = @static 2
stable color: Color = @dynamic ...

This helps identify cases where a default expression is treated as dynamic rather than static.

composables.csv : Contains function-level metrics that can be inspected programmatically or compared between builds.

The compiler metrics documentation explains the report formats and their interpretation:

One limitation matters: these reports don’t expose every generated $changed bit pattern at every call site. They provide compiler-analysis information, not a runtime trace of all bit-level decisions.

For deeper investigation, inspect the compiler’s IR transformation and generated bytecode for a small, isolated example. Avoid depending on synthetic parameter positions or bit encodings in application code.

The optimization is about eliminating information the runtime no longer needs

The static bit is an aspect of a larger change-tracking mechanism.

The compiler categorizes expressions, tracks parameter states via composable calls, and then encodes them as compact integer bit masks. The runtime checks for value equality only if the incoming metadata does not provide a clear outcome.

This means two separate benefits:

  1. Static expressions do not require the runtime to compare parameters or store prior value states for change checking.
  2. If values have been checked by the caller, there is no need to check them multiple times at each composable.

Neither optimization ensures that a composable is skipped from execution. However, both decrease the effort needed to make that decision.

For Android developers evaluating Compose performance, this is an important consideration. Performance of recomposition is not dictated by the number of composables executed but also by how effectively the compiler and runtime determine whether those executions are necessary.

The $changed mask is one of the mechanisms that makes that possible.

Sources


Inside Jetpack Compose’s $changed Bit Field: Static Arguments and Fewer Runtime Comparisons was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.