Case Study 7 min read

Instagram Direct’s AI-Native Compose Migration: 50% Less UI Code and 33% Fewer Tokens

On Sep 30, 2026 the Android Developers Blog published a case study written by Pavlo Stavytskyi (Meta) and Rebecca Franks (Google) on how Instagram Direct moved to Jetpack Compose. The interesting part is not "they migrated" but why they designed the code the way they did: so that AI coding agents produce correct code by default. This post pulls out the architecture rules, the code pattern and the Compose features that made the numbers possible.

~50%
less UI code on migrated surfaces
33%
lower token cost per agent session
35%
less agent execution time per landed code

Scale matters here. The post says Direct handles billions of messages daily, individual components can render in over 160 distinct state permutations, and a single conversation screen handles more than 200 message types.

1. Two rules for AI-native architecture

The team's position is that pointing an AI tool at legacy code gives limited gains, so they redesigned the codebase around two rules:

Takeaway: a lint rule or a type that makes the wrong thing impossible beats a paragraph in an agent instruction file.

2. The core risk: mixing paradigms

During a gradual migration, Compose items live inside an imperative RecyclerView world. The post says AI tends to blend imperative and declarative code, which causes subtle bugs. Their example: a RecyclerViewItem subclass reads a feature flag imperatively, captures it in a Compose lambda and stores a mutable isPinned on the item, and that state survives rebinding and recycling. Moving the logic into a @Composable override still leaves the cross-paradigm coupling.

The fix is to put all Compose code in the constructor, so the item has no members and no state and receives everything through its arguments:

class ChatItem(
  val features: FeatureFlagProvider,
  val onPin: (Boolean) -> Unit,
) : ComposeItem<ChatUiState>(

   // Compose UI
   content = { uiState: ChatUiState ->
    val isPinnedChatsEnabled = features.isEnabled("pinned_chats_feature")

    if (isPinnedChatsEnabled) {
      Button(onClick = { onPin(!uiState.isPinned) }) {
        Text(if (uiState.isPinned) "Unpin" else "Pin")
      }
    }

    ...
  },
)

(The ... is from the original, abridged snippet.) A stateless item can't leak state across recycling, and an agent has nowhere to put an imperative field.

3. Migration process

4. Measuring agent efficiency

The team compared agent sessions on Compose UI against the same tasks on Views, measuring per character of landed code (exchanges, execution time) and per session (token cost). Results reported: 32% fewer engineer-agent exchanges, 35% less execution time, 33% lower token cost. Meta's internal risk score estimates code quality and incident likelihood; when a file's risk score doubles, agent efficiency dropped 30% for Views code versus 9% for Compose.

5. Performance: migrate whole surfaces

Runtime metrics were A/B tested: time to interact, time to fully load, and scroll performance (dropped frames). The post's advice is to migrate end to end, because small isolated migrations give misleading numbers when interop bridging costs distort measurements.

Compose items were first migrated inside the existing RecyclerView and rolled out to small A/B groups. The team then built an interop API so the same Compose items work in both RecyclerView and LazyColumn, with feature flags switching between them at runtime.

6. The Compose features that mattered

Pausable composition (on by default since Compose 1.10) composes expensive lazy items incrementally across frames to prevent jank.

LazyLayoutCacheWindow (Compose 1.9) keeps off-screen items prepared within a band around the viewport. The post's usage, from Compose 1.13.0-alpha03:

val cacheWindow = LazyLayoutCacheWindow(ahead = 150.dp, behind = 100.dp)
// OR
val cacheWindow = LazyLayoutCacheWindow(aheadFraction = 0.5f, behindFraction = 0.3f)

LazyColumn(state = state, cacheWindow = cacheWindow) {
    ...
}

Dp values are fixed lengths; float values are viewport fractions, so the prefetch scales with screen height. Instagram tuned the fractions to Direct's content and item sizes.

onVisibilityChanged (Compose 1.9.0) gives a consistent signal for when a composable is truly on screen, replacing custom implementations. Direct uses it across hundreds of files for impression-based product metrics.

~8%
fewer large frame drops per minute with LazyLayoutCacheWindow alone
~13%
with Pausable composition plus a one-viewport cache window
vs. vanilla
baseline is stock Compose

Pair the two. The post warns the cache window alone can let a heavy item compose in a single pass and overrun the frame budget; Pausable composition spreads that work out.

7. Startup

Compose's runtime warmup cost is paid once. Since messaging is often visited early in a session, other Compose surfaces in Instagram got faster too. Baseline Profiles pre-compile hot paths so Compose renders quickly from first launch.

What to take into your own app

Source: the Android Developers Blog case study, "How Instagram Direct engineers built AI-native UI architecture with Jetpack Compose and reduced token cost per agent session by 33%" (Sep 30, 2026). Feedback on the Compose pieces goes to the issue tracker, component 612128.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment