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.
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:
Legacy code
Navigation extension functions
Custom solutions written to compensate for the shortcomings of the standard library
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
- Compose-first approach: The entire UI and navigation in the application were already built on Compose.
- 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.
- Adaptive Layouts: The business plan included support for tablets and Pixel Fold.
- Google Requirements: Google is actively pushing adaptive layouts and large screens. Furthermore, starting from Android 17, fixed screen orientation effectively becomes a legacy approach for most applications. The Google Play Store will also officially prioritize applications that support large screens.
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:
- Standard back navigation: From the current screen, pressing back will return the user to the previous screen.
- Back navigation 2+ screens back: For example, when blocking a user in a chat, you need to pop the user back several screens.
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:
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:
- Fragment-based architecture: You must completely get rid of Fragments before migrating. This could mean a large-scale refactoring with high risk.
- Tight deadlines: Even if you are already using Compose navigation, the migration creates a huge affected area and can be costly for the business due to regressions.
- Proprietary navigation solutions: If your custom solution is already working stably, the migration might not be justified.
- Games: If your application is a game and you strictly rely on screen orientation (mostly landscape in this case), Google allows exceptions to keep the orientation fixed.
Advantages
Despite all the problems, I believe the migration was fully justified, and ultimately we won. Specifically:
- Multi-pane UI: Adaptive layouts are no longer a problem for us. We can easily implement this by simply specifying the appropriate
sceneStrategyfor specific screens. - Predictable navigation: The navigation stack has become more transparent and easier to debug.
- Deep links: During the migration, I refactored a large amount of legacy code and brought deep links into a clean structure.
Summary
The entire migration process can be roughly summarized as:
- Team: 1 active and not yet burned-out developer
- Development time: 2 weeks
- Resolving merge conflicts: 1 day
- Affected area: 302 files, or simply put — the entire application
- Testing and stabilization: ~1.5 weeks
- Hotfixes: 1
Crucially, we evaluated the success of the migration against several practical criteria:
- Stability of navigation flows after release (crashes and navigation-related regressions).
- Speed of integrating new screens into the new navigation framework.
- Ability to seamlessly build adaptive layouts without additional workarounds.
- Debugging the navigation stack (whether it became easier to reproduce scenarios).
After the migration, the most noticeable change appeared in daily development:
- New screens are easier to wire up for navigation, without extra extension functions.
- The navigation stack is more predictable and easier to debug.
- A significant portion of legacy logic simply disappeared.
- Nav3 proved to be closer to a state-driven approach, and adaptive layouts no longer feel like a huge standalone project.
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!