When I first started working with Jetpack Compose at CodeBrew Labs, I made a critical mistake: I treated it like a traditional imperative UI framework. My team's shopping app had beautiful, modern interfaces—but users reported constant frame stuttering, especially on mid-range devices. The app was recomposing far too aggressively, and I didn't understand why.

That project forced me to deeply understand Android Jetpack Compose performance optimization. After months of profiling, testing, and iterating, we reduced frame drops from 45% to under 15%. In this post, I'm sharing the exact techniques that transformed our Android architecture for Compose-based apps—lessons I've applied across every project since.

The Recomposition Trap

Here's what most developers don't realize: Jetpack Compose recomposes constantly. Every state change, every parent recomposition, every lambda capture triggers a potential recomposition of child composables. The problem? Many of us write code that unnecessarily expands these recomposition scopes.

In my AudioBook AI app, which handles 50K+ users processing large PDF files, a single state update in the main screen was triggering recomposition of the entire bookshelf list. That's hundreds of items re-rendering on every scroll event.

"Recomposition isn't free. Each frame has a 16ms budget on 60Hz displays. Waste it, and users see jank."

The fix wasn't magical—it was understanding composition scope. Not all state updates need to recompose the entire UI tree. The key is knowing which parts should recompose and which should stay stable.

Mastering remember() and derivedStateOf()

When I migrated our Kotlin-based Android apps to Compose, I quickly learned that remember is your primary tool for performance. But it's not just about what you remember—it's about when and how.

remember caches a value across recompositions. If that value doesn't change, the cached result is reused. But here's where most developers stumble: they remember objects that depend on frequently-changing state, defeating the entire purpose.

// ❌ BAD: 'state' changes every render, so this is recalculated constantly
@Composable
fun BookShelfScreen(viewModel: BookViewModel) {
    val books = viewModel.books.collectAsState()
    val sortedBooks = remember {
        books.value.sortedBy { it.title }
    }
    // 'books' is a moving target, remember doesn't cache effectively
}

// ✅ GOOD: Use derivedStateOf for computed values
@Composable
fun BookShelfScreen(viewModel: BookViewModel) {
    val books = viewModel.books.collectAsState()
    val sortedBooks = remember {
        derivedStateOf { books.value.sortedBy { it.title } }
    }
    
    LazyColumn {
        items(sortedBooks.value, key = { it.id }) { book ->
            BookItem(book)
        }
    }
}

derivedStateOf is a game-changer for Android performance optimization. It creates a stable value that automatically recomputes only when its dependencies change. The difference? Compose sees it as a single unit, not a new object every frame.

In EmpSuite ERP, we used derivedStateOf to compute filtered employee lists from a large dataset. Instead of re-sorting 500 employees on every keystroke, the derived state only recalculated when the actual list changed. Frame rate jumped from 24 FPS to 58 FPS.

Composition Scope Isolation

This is the technique that made the biggest difference in my production apps. The core principle: keep recomposition scopes as small as possible.

When a parent state updates, all children recompose by default. But if you structure your composables carefully, children that don't actually use that state remain stable.

// ❌ BAD: Entire screen recomposes when timer updates
@Composable
fun AudioPlayerScreen(viewModel: AudioViewModel) {
    val currentTime = viewModel.currentTime.collectAsState()
    val isPlaying = viewModel.isPlaying.collectAsState()
    
    Column {
        HeaderSection(isPlaying.value) // Recomposes every 100ms
        PlaybackControls(viewModel)     // Recomposes every 100ms
        PlaylistView(viewModel)          // Recomposes every 100ms ← WASTEFUL
    }
}

// ✅ GOOD: Extract stable sections into separate composables
@Composable
fun AudioPlayerScreen(viewModel: AudioViewModel) {
    Column {
        // Only this recomposes when timer updates
        TimerSection(viewModel)
        
        // These remain stable
        PlaybackControls(viewModel)
        PlaylistView(viewModel)
    }
}

@Composable
fun TimerSection(viewModel: AudioViewModel) {
    val currentTime = viewModel.currentTime.collectAsState()
    val isPlaying = viewModel.isPlaying.collectAsState()
    
    Row {
        Text("${currentTime.value}s")
        Icon(imageVector = if (isPlaying.value) Icons.Default.Pause else Icons.Default.PlayArrow)
    }
}

By extracting the timer section into its own composable, only that part recomposes every 100ms. The playlist and controls below remain untouched. This single pattern reduced memory churn by 40% in our AI NoteTaker app.

📖 Pro Tip

Use the "Compose Compiler Metrics" Gradle plugin to see exactly which composables are being recomposed. It's eye-opening.

Profiling Your Compose UI

You can't optimize what you don't measure. In my experience as a Senior Software Engineer, profiling separates the good apps from the great ones.

Android Studio's Compose Layout Inspector shows recomposition counts in real-time. I've spent countless hours staring at the inspector, watching blue highlights (recompositions) spike during specific user actions. Each spike pointed to optimization opportunities.

For deeper analysis, I use:

  • Recompose Counter (built into Android Studio) — see which composables recompose most frequently
  • FrameMetrics API — measure actual frame rendering time in production builds
  • Perfetto Tracing — capture full UI performance traces and identify bottlenecks

At Raybit Technologies, after profiling our remote-first app for 2 hours, I discovered that a single composable was recomposing 150 times per second. A small fix using remember brought that down to 2 times per second. Users immediately noticed the smoothness improvement.

Real-World Optimization Patterns

Beyond the fundamentals, here are patterns I've deployed across multiple production apps:

1. Stable State Holders

Create @Stable data classes for UI state. This tells Compose that if these objects don't change, child composables receiving them won't recompose:

@Stable
data class BookUIState(
    val id: String,
    val title: String,
    val author: String
)

@Composable
fun BookItem(state: BookUIState) {
    // This only recomposes when 'state' changes, not parent updates
    Text(state.title)
}

2. Lambda Hoisting

Lambdas passed as parameters create new function objects, triggering recompositions. Hoist them outside:

// ❌ BAD: New lambda every recomposition
@Composable
fun SearchableList(items: List<String>, viewModel: SearchViewModel) {
    LazyColumn {
        items(items) { item ->
            SearchItem(item, onClick = { viewModel.selectItem(item) })
        }
    }
}

// ✅ GOOD: Hoist callback
val onItemClick: (String) -> Unit = { item ->
    viewModel.selectItem(item)
}
@Composable
fun SearchableList(items: List<String>, onItemClick: (String) -> Unit) {
    LazyColumn {
        items(items) { item ->
            SearchItem(item, onClick = onItemClick)
        }
    }
}

3. Use LazyColumn Keys Effectively

Provide stable, unique keys for items in LazyColumn and LazyRow. Without keys, Compose recomposes items when their position changes, even if content doesn't:

LazyColumn {
    items(books, key = { book -> book.id }) { book ->
        BookItem(book)
    }
}

Key Takeaways

  • Understand recomposition scope — Uncontrolled recomposition is the #1 Compose performance killer. Isolate state to the smallest composables that need it.
  • Leverage derivedStateOf() strategically — Use it for computed values that depend on frequently-changing state. It prevents unnecessary recompositions of children.
  • Profile before optimizing — Android Studio's Layout Inspector and Perfetto reveal exactly where time is wasted. Measure first, optimize second.
  • Extract composables aggressively — Breaking large screens into smaller, focused composables naturally isolates recomposition scopes and improves code maintainability.
  • Stable keys matter — In LazyColumn/LazyRow, always provide stable, unique keys. It's a simple change that prevents cascading recompositions.

I've applied these patterns across Kotlin-based Android applications serving millions of users. The common thread? Performance optimization in Jetpack Compose isn't about magic—it's about understanding how the framework thinks and structuring your Android architecture to work with it, not against it.

Start with profiling, apply these techniques incrementally, and measure the results. Your users will notice the difference immediately.