Compose performance

Jetpack Compose aims to deliver great performance out of the box. This page shows you how to write and configure your app for best performance, and points out some patterns to avoid.

Before you read this, you might want to familiarize yourself with the core Compose concepts in Thinking in Compose.

Properly configure your app

If your app is performing poorly, that might mean there's a configuration problem. A good first step is to check the following configuration options.

Build in release mode and use R8

If you're finding performance issues, make sure to try running your app in release mode. Debug mode is useful for spotting lots of problems, but it imposes a significant performance cost, and can make it hard to spot other code issues that might be hurting performance. You should also use the R8 compiler to remove unnecessary code from your app. By default, building in release mode automatically uses the R8 compiler.

Use a baseline profile

Compose is distributed as a library, instead of being part of the Android platform. This approach lets us update Compose frequently and support older Android versions. However, distributing Compose as a library imposes a cost. Android platform code is already compiled and installed on the device. Libraries, on the other hand, need to be loaded when the app launches, and interpreted just-in-time when the functionality is needed. This can slow the app on startup, and whenever it uses a library feature for the first time.

You can improve performance by defining baseline profiles. These profiles define classes and methods needed on critical user journeys, and are distributed with your app's APK. During app installation, ART compiles that critical code ahead-of-time, so it's ready for use when the app launches.

It's not always easy to define a good baseline profile, and because of this Compose ships with one by default. You might not have to do any work to see this benefit. However, if you choose to define your own profile, you might generate one that doesn't actually improve your app's performance. You should test the profile to verify that it's helping. A good way to do that is to write Macrobenchmark tests for your app, and check the test results as you write and revise your baseline profile. For an example of how to write Macrobenchmark tests for your Compose UI, see the Macrobenchmark Compose sample.

How the three Compose phases affect performance

As discussed in Jetpack Compose Phases, when Compose updates a frame, it goes through three phases:

  • Composition: Compose determines what to show–it runs composable functions and builds the UI tree.
  • Layout: Compose determines the size and placement of each element in the UI tree.
  • Drawing: Compose actually renders the individual UI elements.

Compose can intelligently skip any of those phases if they aren't needed. For example, suppose a single graphic element swaps between two icons of the same size. Since that element isn't changing size, and no elements of the UI tree are being added or removed, Compose can skip over the composition and layout phases and just redraw that one element.

However, some coding mistakes can make it harder for Compose to know which phases it can safely skip. If there's any doubt, Compose ends up running all three phases, which can make your UI slower than it needs to be. So, many of the performance best practices revolve around helping Compose to skip the phases it doesn't need to do.

There are a couple of broad principles to follow that can improve performance in general.

First, whenever possible, move calculations out of your composable functions. Composable functions might need to be re-run whenever the UI changes; any code you put in the composable will get re-executed, potentially for every frame of an animation. So you should limit the composable's code to just what it actually needs to build the UI.

And second, defer state reads for as long as possible. By moving state reading to a child composable or a later phase, you can minimize recomposition or skip the composition phase entirely. You can do this by passing lambda functions instead of the state value for frequently changing state, and by preferring lambda-based modifiers when you pass in frequently-changing state. You can see an example of this technique in the Defer reads as long as possible section.

The following section describes some specific code errors that can cause these sorts of problems. Hopefully, the specific examples covered here will also help you to spot other, similar errors in your code.

Use tools to help find issues

It can be hard to know where a performance issue lies and what code to start optimizing. Start by using tools to help narrow down where your issue is.

Get recomposition counts

You can use the layout inspector to check how often a composable is recomposed or skipped.

Recomposition counts shown in layout inspector

For more information, see the tooling section.

Follow best practices

There are some common Compose pitfalls you might encounter. These mistakes might give you code that seems to run well enough, but can hurt your UI performance. This section lists a few best practices to help you avoid them.

Use remember to minimize expensive calculations

Composable functions can run very frequently, as often as for every frame of an animation. For this reason, you should do as little calculation in the body of your composable as you can.

An important technique is to store the results of calculations with remember. That way, the calculation is run once, and the results can be fetched whenever they're needed.

For example, here's some code that displays a sorted list of names, but naively does the sorting in a very expensive way:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

The problem here is, every time ContactsList is recomposed, the entire contact list is sorted all over again, even though the list hasn't changed. If the user scrolls the list, the Composable gets recomposed whenever a new row appears.

To solve this problem, sort the list outside the LazyColumn, and store the sorted list with remember:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
          // ...
        }
    }
}

Now, the list is sorted once, when ContactList is first composed. If the contacts or comparator change, the sorted list is regenerated. Otherwise, the composable can keep using the cached sorted list.

Use lazy layout keys

Lazy layouts do their best to intelligently reuse items, only regenerating or recomposing them when it has to. However, you can help it to make the best decisions.

Suppose a user operation causes an item to move in the list. For example, suppose you show a list of notes sorted by modification time, with the most recently modified note on top.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

There's a problem with this code, though. Suppose the bottom note gets changed. It's now the most-recently modified note, so it goes to the top of the list, and every other note moves down one spot.

The problem here is, without your help, Compose doesn't realize that unchanged items are just being moved in the list. Instead, Compose thinks the old "item 2" was deleted and a new one was created, and so on for item 3, item 4, and all the way down. The result is, Compose recomposes every item on the list, even though only one of them actually changed.

The solution here is to provide item keys. Providing a stable key for each item lets Compose avoid unnecessary recompositions. In this case, Compose can see that the item now at spot 3 is the same item that used to be at spot 2. Since none of the data for that item has changed, Compose doesn't have to recompose it.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
             key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

Use derivedStateOf to limit recompositions

One risk of using state in your compositions is, if the state changes rapidly, your UI might get recomposed more than you need it to. For example, suppose you're displaying a scrollable list. You examine the list's state to see which item is the first visible item on the list:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

The problem here is, if the user scrolls the list, listState is constantly changing as the user drags their finger. That means the list is constantly being recomposed. However, you don't actually need to recompose it that often – you don't need to recompose until a new item becomes visible at the bottom. So, that's a lot of extra computation, which makes your UI perform badly.

The solution is to use derived state. Derived state lets you tell Compose which changes of state actually should trigger recomposition. In this case, specify that you care about when the first visible item changes. When that state value changes, the UI needs to recompose – but if the user hasn't yet scrolled enough to bring a new item to the top, it doesn't have to recompose.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Defer reads as long as possible

You should defer reading state variables as long as possible. Deferring state reads can help ensure that Compose re-runs the minimum possible code on recomposition. For example, if your UI has state that is hoisted high up in the composable tree and you read the state in a child composable, you can wrap the state read in a lambda function. Doing this makes the read occur only when it is actually needed. You can see how we applied this approach to the Jetsnack sample app. Jetsnack implements a collapsing-toolbar-like effect on its detail screen.

To achieve this effect, the Title composable needs to know the scroll offset in order to offset itself using a Modifier. Here's a simplified version of the Jetsnack code, before the optimization is made:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

When the scroll state changes, Compose looks for the nearest parent recomposition scope and invalidates it. In this case, the nearest parent is the Box composable. So Compose recomposes the Box, and also recomposes any composables inside the Box. If you change your code to only read the State where you actually use it, then you could reduce the number of elements that need to be recomposed.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
    // ...
    }
}

The scroll parameter is now a lambda. That means Title can still reference the hoisted state, but the value is only read inside Title, where it's actually needed. As a result, when the scroll value changes, the nearest recomposition scope is now the Title composable–Compose no longer needs to recompose the whole Box.

This is a good improvement, but you can do better! You should be suspicious if you are causing recomposition just to re-layout or redraw a Composable. In this case, all you are doing is changing the offset of the Title composable, which could be done in the layout phase.

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(y = scrollProvider()) }
    ) {
      // ...
    }
}

Previously, the code used Modifier.offset(x: Dp, y: Dp), which takes the offset as a parameter. By switching to the lambda version of the modifier, you can make sure the function reads the scroll state in the layout phase. As a result, when the scroll state changes, Compose can skip the composition phase entirely, and go straight to the layout phase. When you are passing frequently changing State variables into modifiers, you should use the lambda versions of the modifiers whenever possible.

Here is another example of this approach. This code hasn't been optimized yet:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(Modifier.fillMaxSize().background(color))

Here the box's background color is switching rapidly between two colors. This state is thus changing very frequently. The composable then reads this state in the background modifier. As a result, the box has to recompose on every frame, since the color is changing on every frame.

To improve this, we can use a lambda-based modifier–in this case, drawBehind. That means the color state is only read during the draw phase. As a result, Compose can skip the composition and layout phases entirely–when the color changes, Compose goes straight to the draw phase.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         drawRect(color)
      }
)

Avoid backwards writes

Compose has a core assumption that you will never write to state that has already been read. When you do this, it is called a backwards write and it can cause recomposition to occur on every frame, endlessly.

The following composable shows an example of this kind of mistake.

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read
}

This code updates the count at the end of the composable, after reading it on the line above. If you run this code you'll see that after you click the button, which causes a recomposition, the counter rapidly increases in an infinite loop as Compose recomposes this Composable, sees a state read that is out of date, and so schedules another recomposition.

You can avoid backwards writes altogether by never writing to state in Composition. If at all possible, always write to state in response to an event and in a lambda like in the preceding onClick example.