跪拜 Guibai
← Back to the summary

Migrating 170 Screens to Android Navigation 3: Overtime, Crashes, and Bottom-Sheet Surprises

This article is translated from "Migrating App to Navigation 3: Pain, Overtimes, and Hotfixes", original link https://medium.com/proandroiddev/migrating-app-to-navigation-3-pain-overtimes-and-hotfixes-88321b59314b, published by Tetiana Synytsyna on June 30, 2026.

Generated by Gemini

In early 2026, we migrated our Android app with over 170 screens from Navigation 2 to Navigation 3.

Navigation 3 introduces type-safe destinations, a persistent back stack, and a more modern navigation API. While the overall migration process was relatively smooth, we still encountered some unexpected challenges — from handling bottom sheets to fixing crashes that only appeared after release.

In this article, I will share the reasons we decided to migrate, the migration process, the problems we encountered, and the lessons we learned from them.

If you are planning to migrate to Navigation 3, the experience in this article might save you some time and help you avoid some trouble.

Experience Using Navigation 2

The Situation Before Migration

Our app was developed in 2021, and its navigation foundation was based on Jetpack Compose Navigation, which was a relatively new and not yet fully stable solution from Google at the time.

Therefore, like any large application, we faced:

It is worth mentioning that our bottom sheets were also implemented based on this navigation framework.

The emergence of Navigation 3 fundamentally redefined the approach to navigation.

Why We Decided to Migrate

We began weighing technical debt against future opportunities and ultimately decided to migrate. For us, this was not just "updating a library."

We viewed this migration as an investment in the platform.

First, our product roadmap already covered tablets and foldable devices, and the existing navigation implementation was incompatible with adaptive layouts and multi-pane scenarios.

Second, the navigation stack was gradually being overwhelmed by extension functions and custom logic. Although it worked, the maintenance cost for each subsequent feature in the navigation layer was getting higher and higher.

We understood that migration meant risk, a wide impact area, and potential regressions. However, postponing the migration would only increase technical debt and make future migration more expensive.

What Convinced Us

  1. Compose-first approach: The entire UI and navigation in the application were already built on Compose.
  2. MVI and state-driven navigation: Nav3 treats the navigation stack as regular state. This combined very organically with our MVI architecture.

Conceptually, navigation started to look like this:

val navigationState = rememberNavigationState(
     startKey = MainScreenPointScreen,
     topLevelKeys = setOf(MainScreenPointScreen)
)
val navigator = remember { Navigator(navigationState) }
fun navigate(key: NavKey) {
    when (key) {
       state.currentTopLevelKey -> clearSubStack()
       in state.topLevelKeys -> goToTopLevel(key)
       else -> goToKey(key)
    }
}
fun goBack(): Boolean {
    backStack.removeLastOrNull()
    return true
}

Navigation became very close to the state-driven approach we were already using in other parts of the application.

Therefore, for us, this looked like a choice, "either do it now in a controlled way, or come back later under much greater pressure."

Additionally, I recommend that developers starting new applications design their architecture with Nav3 from the beginning.

How the Migration Happened and Is There Life After?

November 2025

I saw that the stable version of Nav3 had been released, and as a proactive developer, inspired by the new navigation approach, I created an investigation task for the next sprint.

The goal of the investigation was simple: assess the feasibility of migration, the affected areas, the risks, and understand whether it was realistic for the scale of our application. It quickly became clear: this was not going to be easy.

I immediately identified the main risk — incremental migration was impossible. Due to the lack of backward compatibility between Nav2 and Nav3, the conversion could only happen by migrating all screens at once. Partial migration was not an option.

After the investigation, the leadership and I assessed the risks, potential regressions, and the roadmap for the coming quarters. Since adaptive layouts were already planned, the migration was approved.

February 2026

In the next sprint planning, I was assigned a dedicated migration sprint, and for the entire duration (two long, interesting, and painful weeks), I worked exclusively on navigation. The migration was done entirely manually.

Yes, I tried using AI. But it turned out that the migration was too complex for the legacy codebase. Due to extension functions, custom navigation solutions, and significant dependencies, Claude Code sometimes provided inconsistent results for our specific legacy setup. It would not migrate to Nav3 but would return Nav2 code and write: "Migration completed successfully, the project now runs on the latest stable version of navigation." But we will come back to this point later.

Migrating to Nav3 took me two long weeks, with overtime every day. Disclaimer: no, the company did not force me. Yes, they would have given me as much time as needed. Yes, no one made me write this 😄.

Fun fact: Gemini and Claude Code estimated the entire migration would take a team 6 to 8 weeks. In the end, I completed it alone in two weeks.

Overtime. Why?

A major downside of a large-scale refactoring like this is merge conflicts. We deliberately decided not to freeze development during the migration because that would be too costly for the business. While I was performing the migration in parallel, the team continued developing new features.

Here comes the interesting part: while I was manually rewriting every screen in the application, my colleagues were simultaneously continuing to write code. Creating new screens and features. On the old navigation. Yes, I tried to negotiate with them to take a vacation during this time — without success.

So I quickly realized: it was best to finish this migration as fast as possible, because the longer it dragged on, the higher the cost and the more painful it would be to resolve merge conflicts.

Unexpected

Bottom Sheets

When planning the migration, I missed one crucial detail — bottom sheets. Nav3 has no out-of-the-box solution for displaying bottom sheets via navigation. I had to write my own, not very elegant, wrapper around entry<>, passing information in metadata indicating it was a bottom sheet, and if true, displaying this screen via ModalBottomSheetLayout (Material 3) instead of NavDisplay.

val NavEntry<out NavKey>.isBottomSheet: Boolean
    get() = metadata["isBottomSheet"] as? Boolean == true
inline fun <reified T : NavKey> EntryProviderScope<NavKey>.bottomSheetEntry(
    noinline content: @Composable (T) -> Unit,
) {
    entry<T>(
        metadata = mapOf("isBottomSheet" to true),
        content = content
    )
}
NavDisplay(
    entries = navigationState.toEntries(mainEntryProvider)
        .filter { it.isBottomSheet.not() },
    onBack = { navigator.goBack() },
)

This was not an ideal solution — more of a workaround that allowed us to move forward. Today, better approaches exist via SceneStrategy, and we later replaced this implementation.

Deep Links

An unexpected benefit of the migration was the opportunity to properly rethink our deep links. Historically, deep links in our application were not structured optimally, and it was not always obvious which screen handled them.

During the migration, I consolidated them into one place and centralized the logic. This brought a simple but very tangible benefit: it is now much easier to understand exactly what happens with each deep link.

Before:

composable(
     deepLinks = "$uri/${ScreenName.PROFILE.value}/?$PROFILE_ID={$PROFILE_ID}" +
              "&$EVENT_CONTEXT={$EVENT_CONTEXT}" +
              "&$EVENT_ANCHOR={$EVENT_ANCHOR}" +
              "&$ENTRY_POINT={$ENTRY_POINT}" +
              "&$STREAM_ID={$STREAM_ID}",
      route = profileRoute(),
      arguments = profileArguments(),
) {
     ProfilePreview(
        //profile params
     )
}

After (Conceptual):

@Serializable
data class ProfilePreviewPointScreen(
     override val deepLinkPath: String = "${ScreenName.PROFILE.value}"
) : NavKey,
    DeepLinkable
entry<ProfilePreviewPointScreen>(
     metadata = BottomSheetSceneStrategy.bottomSheet()
) { entry ->
    ProfilePreview(
        //profile params
    )
}

It might not look like a massive refactoring, but in practice, it greatly simplified maintenance and debugging.

Stabilization

Full regression testing, stabilization, and bug fixing took about a week and a half. Honestly, I thought we would find bugs and stabilize for another month (just don't tell my lead). With the help of automated tests, we discovered a large number of issues that were very difficult to reproduce manually, but users would definitely leave comments about them.

Release

We stabilized, performed regression, released, opened Crashlytics, rolled out to 1% of users, held our breath, and waited. We didn't wait long — crashes started appearing.

Back Navigation Issues

To understand the nature of these crashes, we need to look at back navigation. There are two variants of this navigation:

Image without caption

Image without caption

For the first case, everything was stable. For the second case, we passed an entryPoint: NavKey parameter through navigation (after migration) to know exactly which screen to return the user to upon blocking.

data class BlockUserRoute(
   val entryPoint: NavKey
)

This led to crashes with the following error: Fatal Exception: kotlinx.serialization.SerializationException.

In Nav3, all parameters passed through navigation must be serializable. Our NavKey was not serializable, so we had to create a wrapper:

@Serializable
open class NavStateSerializable : NavKey

We released a hotfix and successfully completed the full rollout on the second attempt.

Being Proactive Is Good, But Not Always

Remember I mentioned that I migrated everything manually? Tried AI, but it failed. Well, a month and a half after our release, Google launched a Claude Code skill specifically for Nav3 migration.

I was curious how much Claude Code would have helped me. I switched back to the pre-migration branch and tried migrating using Claude Code. I can confidently say it would have saved me a week of work. This means the migration would have been twice as fast. Yes, extensions and legacy code would still require manual migration, and verifying changes after Claude Code would take a significant amount of time, but it is undeniable that Claude Code would optimize a lot of the monotonous manual work I did.

Furthermore, Google has since provided a built-in solution for Bottom Sheets — specifically via SceneStrategy. We have already replaced the custom workaround with BottomSheetSceneStrategy. This approach is much more convenient.

You can find the complete code for BottomSheetSceneStrategy here.

So, if I hadn't rushed the migration and waited a few months, the migration would have been slightly easier and development twice as fast due to Claude Code and the ready-made solution for Bottom Sheets. However, I don't think the time required for stabilization and bug fixing would have changed much.

Conclusions and Lessons Learned

Bottom Sheets... Again

Because our bottom sheets are inside the navigation stack, there is a critical nuance.

Take a closer look at this flow:

Image without caption

Consider this scenario: obviously, a navigation-driven bottom sheet is part of the navigation stack. However, in the case of navigating from a bottom sheet to a subsequent screen, if you don't manually dismiss it, pressing back from that new screen will show the bottom sheet again because it is still on the stack. Therefore, the bottom sheet must be manually dismissed before navigating to another screen. This is easy to forget.

You can always write an extension for this, but it might still not be obvious, especially for new developers on the team.

When I Don't Recommend Migration

I strongly recommend weighing the necessity of moving to Nav3 in the following situations:

Advantages

Despite all the problems, I believe the migration was fully justified, and ultimately we won. Specifically:

Summary

The entire migration process can be roughly summarized as:

Crucially, we evaluated the success of the migration against several practical criteria:

After the migration, the most noticeable change appeared in daily development:

But if you are also planning a migration, allocate more time for stabilization, do not underestimate bottom sheets, and double-check the serialization setup in navigation.

I hope my experience can help you avoid my mistakes and make your migration faster and less painful!

Happy coding! 😃

Welcome to search and follow the WeChat Official Account 「稀有猿诉」 for more high-quality articles!

Protect originality, please do not reprint!