← All posts

Jetpack Compose Migration: Lessons from Nextdoor's Feed

A firsthand account of leading a Compose migration of a high-traffic Android feed with 15+ item types: the challenges, solutions, and lessons that stuck.

Sudhanshu Siddh Senior Software Engineer, Android

In early 2023, I joined Nextdoor’s Feed team to lead an ongoing Compose migration, one of the more technically demanding projects I’ve worked on. Migrating a production feed with 15+ item types surfaces problems a greenfield implementation never would: edge cases in tracking, ads rendering, video playback, and recomposition that only show up at scale, under real usage patterns.

Key takeaways

  • Compose migrations reveal architecture problems as much as UI problems. Profile recompositions before blaming the view code.
  • Compose is still maturing for complex use cases; anything beyond basic UI may require custom implementations or reaching into internals
  • High-level A/B metrics only tell you something is wrong, not where. Build granular instrumentation before rollout.
  • Compose migrations don’t happen in isolation; some solutions require cross-functional collaboration that a single team can’t deliver alone

Why we migrated the Nextdoor feed to Compose

When I joined Nextdoor, most of the app was using Epoxy with MVRx, but the feed was still on native views. In late 2021, the company launched a project to migrate the feed to Epoxy. At the same time, the company had started experimenting with Jetpack Compose, and I was one of the early adopters: I introduced Compose on the search surface and contributed to building foundational UI components for our design system.

By the end of 2022, all design system components had moved to Compose, which teams across the app were adopting. The design system team also announced they were dropping Epoxy support; all new components would be Compose only.

The feed Epoxy migration had also exposed gaps in our existing stack. During that project, ads fetching and rendering broke and caused a measurable regression in ad impressions, a production incident that was a direct motivator for what came next. That combination of a Compose-only design system, app-wide adoption, and unresolved problems on the existing stack made migrating the feed to Compose inevitable.

Nextdoor brought me onto the Feed team in early 2023 to lead the migration.

Challenges we didn’t anticipate

Migrating a high-traffic, feature-dense feed revealed five major problems.

1. View tracking

We built the view tracking logic on onViewAttachedToWindow and onViewDetachedFromWindow RecyclerView hooks. Retrofitting it for Compose dropped both impressions and opportunities in early test runs, reproducibly.

2. Ads

About 30% of our ads were served by the Nextdoor Ad Server (NAS), where we had full control over rendering. The rest came through Google Ads Manager (GAM), which had no Compose support. That created a reproducible, measurable impression gap for a significant portion of our ad inventory, and was worst for the UnifiedNativeAd ad type.

3. Videos

There was no native composable for video playback when we started the migration. Every new video entering the viewport stuttered when we layered native views inside Compose. On a video-heavy feed like Nextdoor’s, that was a hard blocker.

4. Performance

Strong skipping wasn’t available at the time. We also scoped the migration to the UI layer with no architectural improvements. Together these produced excessive recompositions. Feed scroll was impossible.

5. Data gaps

Our A/B testing framework provided high-level metrics but not the granular data we needed. When key metrics showed decline, it was hard to tell where the regressions were actually coming from.

How we solved each one

1. View tracking

The original tracking logic was tightly coupled to RecyclerView’s view lifecycle via onViewAttachedToWindow and onViewDetachedFromWindow. Any direct port to Compose would inherit the same fragility. I started by decoupling the tracking manager from the UI framework entirely: it only cared about what came into view and what went out, and fired tracking events accordingly.

From there, the platform team and I built a custom modifier around onGloballyPositioned that invoked tracking functions as feed items entered the viewport. Rebecca Franks has a good reference for this pattern in the JetpackComposeApp newsletter.

2. Ads

I built a custom composable wrapper around UnifiedNativeAdView to get impression events firing correctly. The root issue was that Google’s ad view relies on going through a layout pass to fire impressions, and that pass wasn’t happening inside Compose’s rendering pipeline.

The fix was to manually call forceLayout on AndroidViewsHandler, the internal class Compose uses to manage interop views. The 200ms delay in requestLayoutWithDelay was empirically derived through testing, not a conservative guess. The AndroidViewsHandler string-based class lookup is a workaround for the version of Compose we were on at the time; the underlying issue was later fixed in a newer Compose release. The implementation was based on a discussion in the Kotlin Slack:

@Composable
private fun UnifiedNativeAdInterop(
    data: UnifiedNativeAdWrapper,
    index: Int,
    action: (action: UiAction) -> Unit,
    modifier: Modifier = Modifier,
) {
    var nativeView by remember(data.id) { mutableStateOf<UnifiedAdAndroidView?>(null) }
    var hasRequestedLayout by remember(data.id) { mutableStateOf(false) }

    AndroidView(
        modifier =
            modifier.fillMaxSize().onGloballyPositioned {
                nativeView?.let { view ->
                    if (view.root().tag != true && !hasRequestedLayout) {
                        hasRequestedLayout = true
                        view.root().requestLayoutWithDelay(200, 0, 10)
                    }
                }
            },
        factory = { context ->
            UnifiedAdAndroidView(context)
                .apply {
                    isSaveEnabled = false
                    setData(data, index = index, action)
                }
                .also { nativeView = it }
        },
        update = {},
    )
}

fun View.requestLayoutWithDelay(
    delayMillis: Long,
    retryCount: Int,
    limit: Int,
) {
    postDelayed(
        {
            var parentInCheck = this.parent
            var level = 0
            while (
                parentInCheck?.let { it::class.simpleName } != "AndroidComposeView" && level < 5
            ) {
                level += 1
                parentInCheck = parentInCheck?.parent
            }

            if (parentInCheck == null) {
                parentInCheck = parent?.parent?.parent?.parent
            }

            if (parentInCheck == null && retryCount < limit) {
                requestLayoutWithDelay(delayMillis, retryCount + 1, limit)
            } else if (parentInCheck != null) {
                if (parentInCheck is ViewGroup) {
                    for (i in 0 until parentInCheck.childCount) {
                        val child = parentInCheck.getChildAt(i)
                        if (child::class.java.name.contains("AndroidViewsHandler")) {
                            child.forceLayout()
                            break
                        }
                    }
                }
                parentInCheck.requestLayout()
                tag = true
            }
        },
        delayMillis,
    )
}

This restored full impression parity with the old stack.

3. Videos

After profiling the feed with Perfetto, we traced the scroll bottleneck to ExoPlayer instance creation and player setup. I built a shared pool of ExoPlayer instances feed items could draw from. We flushed players on return to the pool to guarantee clean reuse (no audio bleed or state carryover). Pooling the player views themselves dropped scroll jank further.

The Reddit engineering team ran into similar challenges and cover their approach in much more detail in their Droidcon NYC ‘24 talk.

4. Performance

We used Rebugger, custom logging, and Layout Inspector to trace what was causing recompositions. Two root causes: unstable model types flowing into the Compose hierarchy, and the compiler lacking enough information to skip recompositions safely.

We ran stability checks across the component tree: applying @Stable and @Immutable annotations where appropriate, switching to immutable collections, restructuring models to pass the compiler’s stability checks. Then we introduced a UI model layer between our domain/network models and the Compose hierarchy that only exposed the fields needed for rendering. This kept the stability surface small. Domain model churn no longer propagated as recompositions.

Before this fix, feed scroll was unusable in test builds. After, it was smooth.

5. Data analysis

I built a custom dashboard that analyzed experiment data at a much more granular level: impressions and opportunities broken down by slot number, ad impressions split by format type and supply source, and client actions viewed from multiple angles. Without it, we would have missed non-obvious bugs in the Compose version.

The dashboard surfaced the UnifiedNativeAd impression gaps I described above. It also caught a drop in feed refresh numbers that pointed to a logic bug in the Compose treatment, which would have been invisible from top-level metrics alone.

Results

Three quarters from start to full rollout. The regressions we caught in testing stayed in testing. Nothing required a hotfix post-launch.

The bigger payoff was developer velocity. After the migration, Android consistently finished new feature UI before other platforms. We started last; we ended first.

Frequently asked questions

How did you fix Google Ads Manager impression tracking in Compose?

At the time of our migration, GAM’s UnifiedNativeAdView relied on a layout pass to fire impressions. That pass doesn’t run inside Compose’s rendering pipeline. We worked around it by manually calling forceLayout on AndroidViewsHandler via onGloballyPositioned. The underlying issue was fixed in a later Compose release, so check your Compose version before implementing this pattern.

What caused excessive recompositions in the feed?

Two root causes: unstable model types flowing into the Compose hierarchy, and the compiler lacking enough information to skip recompositions safely (Strong Skipping wasn’t available at the time). Fixing it required both stability annotations (@Stable/@Immutable, immutable collections) and a new UI model layer that isolated the Compose tree from domain model churn. Neither alone was enough.

How do you track item visibility in Jetpack Compose?

Decouple the tracking manager from the view framework. It should only care about what enters and exits the viewport. Then build a custom modifier using onGloballyPositioned to invoke tracking functions as composables enter view. Rebecca Franks documents this pattern in the JetpackCompose.app newsletter.

How do you avoid scroll jank with video in a Compose list?

Pool pre-initialized ExoPlayer instances so feed items draw from the pool on entry and return on exit. Flush player state on return to prevent audio bleed between items. For further gains, pool the player views themselves. This reduces allocation cost on each scroll entry.

Lessons learned

Architecture matters as much as the UI layer. The most impactful performance improvement was a UI model layer that ensured only stable data flowed down the hierarchy. We got there by profiling and reading Rebugger logs until we understood exactly what was triggering recompositions. Rebugger pointed us to the model layer, not the UI code. When you migrate to Compose, check whether your architecture fits the framework, not just whether your views do.

Compose is still maturing for certain use cases. The native view system had years to develop edge-case handling. For ads and video, we had to build custom implementations from scratch rather than reach for a ready-made composable. In the case of ads, we were reaching into Compose internals to work around a layout pass issue that wasn’t exposed through any public API. Scope custom implementation time for any use case beyond basic UI.

Define success early and invest in instrumentation. A/B testing is a requirement. But high-level metrics will only tell you something is wrong, not where. The custom dashboard let us isolate the UnifiedNativeAd impression gap and the feed refresh regression. Top-level metrics would have missed both. These migrations grow. A clear definition of done makes the hard calls easier.

Migrations don’t happen in isolation. The tracking modifier required the platform team. Without them, the tracking solution wouldn’t have been correct. Engineers across Platform, Ads, and Feed made this work.