NAV
The Complete History · 2008 → 2025

Android
Navigation
Evolution

From raw Activity intents to type-safe Compose routes — every era, every problem it introduced, every solution that came next.

I · Activity Era
II · Fragment Era
III · Nav Component
IV · Compose Nav
2008
Android 1.0
Era I · 2008 — 2011
Activity-only
Navigation

Every screen is an Activity. Navigation is just startActivity(). Simple, direct, and dangerously limited.

Android launched with a beautifully simple navigation model: each screen is a self-contained Activity, and you navigate by firing an Intent. The OS manages a back stack automatically. No framework code, no abstractions — just platform primitives. For simple apps, this was elegant. As apps grew complex, the cracks appeared fast.

How it worked

Java · Android 1.0 navigation
// Navigate to another screen — that's it
Intent intent = new Intent(this, DetailActivity.class);
intent.putExtra("product_id", productId);  // raw String key — no type safety
startActivity(intent);

// Reading the argument in DetailActivity
String productId = getIntent().getStringExtra("product_id");
// Null? Wrong key? Wrong type? Runtime crash. No compile-time check.

// For result — back in the old days
startActivityForResult(intent, REQUEST_CODE);   // deprecated in 2021

// Receive in the original activity
@Override
protected void onActivityResult(int reqCode, int resCode, Intent data) {
    if (reqCode == REQUEST_CODE && resCode == RESULT_OK) {
        String result = data.getStringExtra("result");  // magic string
    }
}
Strengths
Zero framework overhead — pure platform primitives
OS manages back stack automatically, no code required
Deep links work out of the box via Intent filters in manifest
Each screen is fully independent — easy to test in isolation
Launch modes (singleTop, singleTask) give powerful routing control
Pain Points
No type safety — Intent extras are raw key-value strings, crash at runtime
Every screen = full Activity = heavy memory and lifecycle overhead
No shared UI elements — each screen redraws the entire view hierarchy
startActivityForResult is verbose and hard to reason about at scale
Back stack is global — impossible to scope navigation to a sub-flow
Navigation logic scattered across every Activity, no central map
Animations between screens are jarring without custom transitions

The crunch point: As apps grew from 3 screens to 30, teams realized that having 30 Activity subclasses, each potentially a 1000-line God class managing its own lifecycle, state, and navigation, was becoming unmanageable. The app's navigation graph existed only in developers' heads.

2011
Android 3.0 (API 11)
Era II · 2011 — 2018
FragmentManager
& Transactions

Fragments promised modular UI and shared lifecycle. They delivered modularity — and a decade of memorable bugs.

Introduced in Android 3.0 (Honeycomb) for tablet layouts, Fragments quickly became the de-facto navigation primitive for phone apps too. The idea was sound: lightweight UI modules inside an Activity, reusable, composable, back-stack-aware. Reality was more complicated.

Solved from Era I
Lightweight screens — Fragments share one Activity window, cheaper than multiple Activities
Reusable UI modules — same Fragment in phone list/detail and tablet split-view
Fragment back stack with addToBackStack() — nested navigation without new Activities
Shared Toolbar and navigation drawer across screens in one Activity

The FragmentManager & Transaction model

Every navigation action was a FragmentTransaction — a unit of work that adds, replaces, removes, or hides fragments, optionally saving state to the back stack. Multiple operations could be batched into one atomic transaction.

Kotlin · FragmentManager navigation
// Navigate to a detail screen
supportFragmentManager
    .beginTransaction()
    .setCustomAnimations(
        R.anim.slide_in_right, R.anim.slide_out_left,   // enter / exit
        R.anim.slide_in_left,  R.anim.slide_out_right   // popEnter / popExit
    )
    .replace(R.id.container, DetailFragment.newInstance(productId))
    .addToBackStack("detail")  // name for selective popping
    .commit()                    // async — or commitNow() for sync

// Passing arguments — still stringly-typed in this era
companion object {
    fun newInstance(id: String) = DetailFragment().also {
        it.arguments = bundleOf("product_id" to id)
    }
}

// Receiving arguments in the Fragment
val productId = requireArguments().getString("product_id")
    ?: error("Missing product_id argument")  // still runtime crash

// Pop back stack
parentFragmentManager.popBackStack()
// Or pop to a specific tag
parentFragmentManager.popBackStack("home", 0)
Strengths
One Activity hosts all screens — shared ActionBar, Drawer, BottomNav
Flexible transactions — add, replace, hide, show, detach, attach
Custom enter/exit animations per transaction
Fragment back stack is scoped — doesn't pollute app task
Shared element transitions possible between fragments
Pain Points
FragmentManager state is opaque — no visual map of current stack
Arguments still untyped strings — runtime crashes for typos
commit() is asynchronous — calling after onSaveInstanceState throws IllegalStateException
Fragment lifecycle differs subtly from Activity — onViewCreated, onDestroyView confusion
Navigation logic scattered across every Fragment — no central routing
Deep linking requires manual Intent parsing in every Activity
Back stack state not preserved through process death correctly
Child fragment managers, nested transactions — minefield of bugs
🔥

"Fragments are a mistake" became a common take by 2015. The IllegalStateException: Can not perform this action after onSaveInstanceState error alone became one of the most Googled Android errors of all time. Teams started building their own navigation libraries (Conductor, Cicerone, Flow) to escape FragmentManager.

The "Single Activity" pattern

By 2016–2017, the community had converged on a best practice: one Activity per logical flow (or even per app), with Fragments as screens. This reduced overhead but made the navigation problem worse — now all navigation had to go through FragmentManager, and there was no enforced architecture for it.

The "Router" anti-pattern that emerged
// Teams invented their own routers...
class Navigator(private val activity: AppCompatActivity) {

    fun goToDetail(productId: String) {
        activity.supportFragmentManager
            .beginTransaction()
            .replace(R.id.container, DetailFragment.newInstance(productId))
            .addToBackStack(null)
            .commit()
    }

    fun goToProfile(userId: String) { /* ... */ }
    fun goToCheckout(cartId: String) { /* ... */ }
    fun goToSettings() { /* ... */ }
    // ... 20 more methods growing forever, no tests
}
// Each team had a different version of this. Each had different bugs.

The real problem revealed: Navigation is fundamentally a graph problem. FragmentManager gives you the tools to traverse a graph but no way to declare the graph itself. Every app was reimplementing graph traversal from scratch, with different bugs each time.

2018
Android Studio 3.2
Era III · 2018 — 2022
Jetpack Navigation
Component

Google finally provides the navigation graph. One XML file declares all screens, all routes, all arguments. The single source of truth teams had been hand-rolling for years.

Announced at Google I/O 2018 and released as part of Android Jetpack, the Navigation Component was a direct response to years of community pain. The core idea: declare your app's navigation as a directed graph in XML. A single NavController traverses it. No more scattered FragmentManager calls — just navigate(R.id.action_home_to_detail).

Solved from Era II
Visual navigation graph — entire app flow visible in one XML diagram in Android Studio
Safe Args plugin generates type-safe argument classes — no more magic strings
Deep links declared in the nav graph — handled automatically by NavController
Single NavHostFragment replaces all the custom router/container patterns
Correct back stack behavior by default — popUpTo and popUpToInclusive
NavController.navigateUp() handles Up button correctly across all destinations

The NavGraph — navigation as data

XML · nav_graph.xml
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:startDestination="@id/homeFragment">

    <fragment
        android:id="@+id/homeFragment"
        android:name="com.app.HomeFragment">

        <action
            android:id="@+id/action_home_to_detail"
            app:destination="@id/detailFragment"
            app:enterAnim="@anim/slide_in_right"
            app:exitAnim="@anim/slide_out_left"/>
    </fragment>

    <fragment
        android:id="@+id/detailFragment"
        android:name="com.app.DetailFragment">

        <argument
            android:name="productId"
            app:argType="string" />       <!-- Safe Args generates type-safe class -->

        <deepLink
            app:uri="https://myapp.com/product/{productId}" />
    </fragment>

    <dialog android:id="@+id/confirmDialog" ... />
</navigation>

Safe Args — type safety at last

Kotlin · SafeArgs navigation
// Navigate with compile-time checked arguments
val action = HomeFragmentDirections.actionHomeToDetail(
    productId = "prod_123"   // generated function, typed parameter
)
findNavController().navigate(action)

// Read in DetailFragment — also type-safe
val args: DetailFragmentArgs by navArgs()
val productId: String = args.productId   // compile-time guaranteed non-null String

// Navigate to a bottom nav tab, clearing the stack
val options = navOptions {
    popUpTo(R.id.homeFragment) { inclusive = false }
    launchSingleTop = true
    restoreState = true
}
findNavController().navigate(R.id.feedFragment, null, options)

// NavController observes back stack as Flow
findNavController().addOnDestinationChangedListener { _, dest, _ ->
    binding.toolbar.title = dest.label
}

// NavGraph scoped ViewModel — shared across sub-flow
val checkoutVm: CheckoutViewModel by navGraphViewModels(R.id.checkout_graph)
Strengths
Visual graph in Android Studio — click any screen to see all its routes
Safe Args generates fully typed argument classes at compile time
Deep links declared once in graph — NavController parses and handles them
NavGraph-scoped ViewModels for sharing state across a multi-step flow
BottomNavigationView + NavigationView integration built in
Correct back stack by default — no more manual FragmentManager calls
Transition animations and shared element support built in
Pain Points
XML nav graph is a separate file — navigation and code are split in different languages
Safe Args requires Gradle plugin — adds build time, slow code generation
Still uses Fragments under the hood — all their lifecycle complexity remains
Dynamic navigation (runtime-determined graphs) is awkward
Multiple back stacks (for BottomNav) required version 2.4 to fix properly
Not designed for Compose — bolted on later with additional complexity
Deep link URL patterns are stringly-typed in the XML
Argument type system limited — no custom types without Parcelable

The real win: Safe Args eliminated an entire class of runtime crashes that had plagued Android apps for years. The generated Directions and Args classes meant the compiler caught argument name typos and type mismatches that previously only surfaced in production.

Navigation 2.4 (2022): Added multiple back stack support — restoreState = true in nav options. This finally made BottomNavigationView correctly preserve state per tab. Previously, switching tabs would destroy and recreate the Fragment stack, losing scroll positions and loaded data.

2021
Compose 1.0 stable
Era IV · 2021 — present
Jetpack Compose
Navigation

Navigation as Kotlin code. No XML, no annotation processing, no Fragments. Screens are composable functions; routes are strings or — since 2.8 — type-safe Kotlin objects.

Jetpack Compose reinvented Android UI with a declarative, functional model. Navigation had to follow. navigation-compose replaced the XML graph with a Kotlin DSL: screens are composable lambdas, registered with string routes on a NavHost. The NavController API stayed familiar, but everything underneath changed.

Solved from Era III
No XML — navigation graph is Kotlin code, lives alongside the UI code, refactorable
No Fragments — composable functions are lightweight, no dual lifecycle complexity
No annotation processing — no Safe Args Gradle plugin, faster builds
Composable destinations are functions — easy to unit test with TestNavHostController
Navigation 2.8: type-safe routes with Kotlin Serialization replace string routes entirely

Compose Navigation — string routes era (2021–2024)

Kotlin · navigation-compose 2.7 (string routes)
// NavHost defines the entire graph as a Kotlin DSL
@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(navController, startDestination = "home") {

        composable("home") {
            HomeScreen(
                onProductClick = { id ->
                    navController.navigate("detail/$id")  // still stringly-typed
                }
            )
        }

        composable(
            route = "detail/{productId}",
            arguments = listOf(navArgument("productId") { type = NavType.StringType })
        ) { backStackEntry ->
            val productId = backStackEntry.arguments?.getString("productId")!!
            DetailScreen(productId = productId, onBack = { navController.popBackStack() })
        }

        composable("settings") { SettingsScreen() }

        navigation(startDestination = "cart", route = "checkout_flow") {
            composable("cart") { CartScreen() }
            composable("payment") { PaymentScreen() }
            composable("confirmation") { ConfirmationScreen() }
        }
    }
}

Navigation 2.8 — Type-safe routes (2024)

Navigation 2.8 introduced type-safe routes using Kotlin Serialization. Routes are now data classes or objects — not strings. This is the approach that finally achieves what Safe Args did for XML navigation, but natively in Kotlin without code generation.

Kotlin · navigation-compose 2.8 (type-safe routes)
// Route definitions — plain Kotlin, fully serializable
@Serializable
object HomeRoute

@Serializable
data class DetailRoute(val productId: String)

@Serializable
object CheckoutGraph          // nested graph route

@Serializable
object CartRoute

// NavHost with type-safe destinations
@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(navController, startDestination = HomeRoute) {

        composable<HomeRoute> {
            HomeScreen(
                onProductClick = { id ->
                    navController.navigate(DetailRoute(productId = id))  // ← type-safe!
                }
            )
        }

        composable<DetailRoute> { backStackEntry ->
            val route: DetailRoute = backStackEntry.toRoute()   // no getString() needed
            DetailScreen(productId = route.productId)          // compile-time guaranteed
        }

        navigation<CheckoutGraph>(startDestination = CartRoute) {
            composable<CartRoute> { CartScreen() }
        }
    }
}

// Navigate — no strings, no magic, full IDE refactoring support
navController.navigate(DetailRoute(productId = "prod_42"))
navController.navigate(CheckoutGraph)
navController.popBackStack<HomeRoute>(inclusive = false)
Strengths
Pure Kotlin — graph, routes, arguments all in one language, fully refactorable
Type-safe routes (2.8) — data classes carry arguments, compile-time checked
No annotation processing — faster build times vs Safe Args
No Fragment lifecycle — composable functions have clean, simple lifecycle
Deep links with type safety — URL templates auto-generated from route data classes
Predictive Back gesture natively supported in Navigation 2.7+
Animated transitions built into Compose — no XML anim resources needed
Pain Points
No visual graph — the XML editor was gone; navigation graph lives only in code
Pre-2.8 string routes were arguably worse than Safe Args — easy to typo
Requires Kotlin Serialization plugin (2.8+) — additional build dependency
ViewModel scoping with navGraphViewModels() more complex in Compose
Complex transitions (shared elements, predictive back) still require extra setup
Nested nav graphs need discipline — easy to create deeply nested NavHosts
Still maturing — API changes between minor versions have been common

Navigation 2.8 is the endgame for type safety. Routes as serializable data classes means your IDE can rename DetailRoute.productId and every navigate call updates. No code generation, no XML, no magic strings. This is what Android navigation should have been from the start.

Passing complex types

Kotlin · Custom NavType for complex args
// Custom NavType for non-primitive types
@Serializable
data class SearchRoute(
    val query: String,
    val filters: List<String> = emptyList(),   // supported via serialization
    val page: Int = 0
)

// Passing nested serializable types via custom NavType
@Serializable
data class ProfileFilter(val showPrivate: Boolean, val sortBy: String)

val ProfileFilterNavType = object : NavType<ProfileFilter>(isNullableAllowed = false) {
    override fun serializeAsValue(value: ProfileFilter) =
        Uri.encode(Json.encodeToString(value))
    override fun parseValue(value: String) =
        Json.decodeFromString(Uri.decode(value))
    override fun get(bundle: Bundle, key: String) =
        bundle.getString(key)?.let { parseValue(it) }
    override fun put(bundle: Bundle, key: String, value: ProfileFilter) =
        bundle.putString(key, serializeAsValue(value))
}
Reference · All Eras
The Full Comparison

Every dimension, every era — the complete decision matrix for choosing a navigation approach in your Android project.

Feature / Property Era I · Activity Era II · Fragment Era III · Nav Component Era IV · Compose Nav
Year introduced2008201120182021 (2.8: 2024)
Type-safe argumentsNone — raw Intent extrasNone — raw Bundle stringsSafe Args (generated)Type-safe routes (2.8+)
Visual graphNoNoXML editor in Android StudioCode only (no diagram)
Navigation declared inCode (imperative)Code (imperative)XML (declarative)Kotlin DSL (declarative)
Deep linksManual Intent filtersManual parsingDeclared in graph XMLAuto from route class
Back stack scopingApp-level onlyFragment back stack (manual)NavGraph, popUpToNavGraph, popBackStack
Shared ViewModel scopeNot possibleActivity scope onlyNavGraph-scoped ViewModelnavGraphViewModels
Fragment dependencyNoneFullFull (Fragment-backed)None (Compose)
Animation supportActivity transitionsManual XML animsDeclared in graphCompose AnimatedContent
Predictive BackManual overrideManual overrideNav 2.7+ (partial)Nav 2.7+ natively
Build time impactNoneNoneSafe Args adds annotation processingkotlinx.serialization only
TestabilityActivityTestRuleFragmentScenarioTestNavHostControllerTestNavHostController
Multiple back stacksNoComplex workaroundsNav 2.4+ (restoreState)Built in
Learning curveLow (platform APIs)Medium (lifecycle traps)Medium (XML + Kotlin)Medium (Compose required)
Recommended for new appsNoNoView-based apps onlyYes — Compose apps

The thread through every era

Activity Era2008 · Android 1.0
Platform gives you everything you need but nothing more. Navigation exists — it's just raw Intent strings. The back stack works perfectly. Arguments are strings. Works fine for 5 screens; becomes unmaintainable at 30.
Fragment Era2011 · Android 3.0
Introduces lightweight UI modules and the Fragment back stack. Solves the "30 heavy Activities" problem. Creates a new problem: two lifecycle systems, imperative navigation logic scattered everywhere, and the infamous commit-after-save crash.
Nav Component2018 · Jetpack
The navigation graph becomes first-class. Declares all destinations, routes, and arguments in one place. Safe Args eliminates string-key bugs. Built-in deep links. But still XML, still Fragments, still code generation. A huge step forward.
Compose Nav2021 · + 2.8 (2024)
Navigation merges with the UI framework — screens are functions, routes are data classes, arguments are constructor parameters. Navigation 2.8 type-safe routes eliminate the last remaining magic strings. The graph lives in Kotlin, refactorable by the IDE like any other code.
The pattern
Each era solved the last era's
worst pain point.
Activity → FragmentToo much memory per screen → lightweight modules
Fragment → Nav ComponentScattered routing logic → single graph declaration
Nav Component → ComposeXML + codegen → pure Kotlin type safety
String routes → 2.8 routesMagic strings → serializable data classes
Full Comparison
Every Era,
Every Dimension

A deep, dimension-by-dimension breakdown of all four eras — scoring, migration paths, gotchas, and a decision guide for your current project.

01 · Era Scorecard — rated 1–5 per dimension
Dimension
ERA I
Activity
ERA II
Fragment
ERA III
Nav Component
ERA IV
Compose Nav
Type safety
Args & routes
Dev experience
Boilerplate & clarity
Deep linking
URL routing support
Testability
Unit & UI tests
Animations
Transitions & motion
Back stack control
Fine-grained pop logic
Build speed
Compilation overhead
Maintainability
Large-team scale
TOTAL SCORE
out of 40
20
50%
18
45%
29
72%
39
97%
02 · Dimension deep dives
Type Safety Evolution
Arguments & route parameters
ERA I–II
Raw Intent.putExtra("key", value) and getStringExtra("key"). Typos in the key string crash at runtime, never at compile time. Wrong type = ClassCastException in production.
ERA III
Safe Args generates HomeFragmentDirections.actionHomeToDetail(id). Compile-time checked. But requires a Gradle plugin and an annotation processor — slow builds, generated files to manage.
ERA IV
@Serializable data class DetailRoute(val id: String). Navigate with navController.navigate(DetailRoute("42")). IDE refactors rename both the route and all navigate calls. Zero code generation.
Deep Linking Evolution
External URL → in-app destination
ERA I
Intent filters in manifest handle URLs. Each Activity must parse its own URL segments manually in onNewIntent(). Back stack is wrong — user lands on destination with nothing behind it.
ERA II
Worse than Era I — deep links must still land on an Activity, which then manually navigates to the right Fragment. Two-hop routing with manual URL parsing at both stages.
ERA III–IV
Declare <deepLink app:uri="app://product/{id}"/> in the graph. NavController handles parsing, constructs the correct back stack automatically. Era IV auto-generates URLs from route class fields.
Back Stack Control
Precision pop & multi-stack
ERA I
The OS back stack is clean and automatic. Launch modes (singleTop, singleTask) give power. But it's binary — the stack is one global thing. No way to scope navigation to a sub-flow.
ERA II
Fragment back stack adds a layer inside one Activity. popBackStack("tag", 0) pops to a named entry. Multiple tabs require manual state saving — each tab switch destroys and recreates the Fragment stack.
ERA III
popUpTo(R.id.home) { inclusive = false } pops to a named destination. Nav 2.4 adds restoreState = true — each BottomNav tab gets its own back stack that is preserved when switching. Game-changer.
ERA IV
navController.popBackStack<HomeRoute>(inclusive = false). Type-safe pop with reified type parameter. Multiple back stacks fully supported. Predictive Back gesture previews the destination before completing the swipe.
Testability
Unit, integration & UI tests
ERA I–II
Testing navigation means launching real Activities with Espresso. intended(hasComponent(DetailActivity.class)). Slow, flaky, requires emulator. Logic and navigation are entangled — hard to test in isolation.
ERA III
TestNavHostController is a fake NavController you can inject. Assert assertEquals(R.id.detailFragment, navController.currentDestination?.id). No real navigation needed — fast unit tests.
ERA IV
Composable screens are functions — testable with composeTestRule.setContent { }. NavController assertions use type-safe routes: assertEquals(DetailRoute("42"), navController.currentBackStackEntry?.toRoute()).
03 · Migration paths
Era I
Era III
Multiple Activities → Single Activity + Nav Component
Strategy
Keep one root Activity. Convert each secondary Activity to a Fragment. Create a nav graph. Replace startActivity() calls with findNavController().navigate(). Migrate screens incrementally — one Activity per sprint.
Gotchas
Activities that use launchMode="singleInstance" cannot be migrated to Fragments — they need to stay as Activities. Back stack behavior may differ for deep-linked screens. Test every deep link after migration.
Era III
Era IV
Nav Component → Compose Navigation
Strategy
Use the interop pattern: embed a ComposeView inside existing Fragments to migrate screen-by-screen. Once all screens are Compose, replace Fragment destinations in the nav graph with composable { } blocks. Flip the NavHost last.
Gotchas
Safe Args generated classes don't map to Compose routes automatically — you must redeclare argument types as @Serializable route data classes. Shared element transitions need the Compose API, not the XML-based one.
Nav 2.7
Nav 2.8
String routes → Type-safe routes
Strategy
Add kotlinx.serialization plugin. For each string route, create a corresponding @Serializable object or data class. Replace composable("route/{arg}") with composable<RouteClass>. Replace backStackEntry.arguments?.getString() with backStackEntry.toRoute(). Can be done file-by-file.
Gotchas
Deep link URL templates that were explicit strings in 2.7 are now auto-generated from field names in 2.8 — verify your external deep link URLs haven't changed. Any place you were matching routes by string (analytics, crash reporting) needs updating.
04 · Which should you use today?
Greenfield app in 2025
Compose Navigation 2.8+
Type-safe routes, no annotation processing, no XML, testable composable screens. This is the path Google has invested in. Start with navigation-compose and kotlinx.serialization.
implementation("androidx.navigation:navigation-compose:2.8.x")
id("org.jetbrains.kotlin.plugin.serialization")
Existing View-based app
Nav Component + Safe Args
If you have a large existing codebase with Views and Fragments, Nav Component 2.7+ is the right answer. Migrate to Compose incrementally as screens are rewritten — don't do a big-bang rewrite.
implementation("androidx.navigation:navigation-fragment-ktx:2.7.x")
id("androidx.navigation.safeargs.kotlin")
Avoid in new code
Raw FragmentManager
Writing supportFragmentManager.beginTransaction()…commit() directly for navigation in 2025 is unnecessary. Nav Component wraps all of this with a safer, tested API. The only valid use is for non-navigation Fragment operations (dialogs, bottom sheets not in the nav graph).
Avoid in new code
Multiple Activity navigation
Having 10+ Activities as screens — each started with raw startActivity() — is the 2011 pattern. It has no type safety, no visual graph, no shared UI, and no back stack control. Only use multiple Activities for true process separation (e.g., a share extension, a lockscreen widget).
05 · Key concepts across all eras
Back Stack
A LIFO stack of destinations. Pressing Back pops the top entry. The system manages one at the Task level (Activities); Nav Component adds its own stack layer (Fragments/Composables). They can coexist — a single-Activity app uses Nav's stack, not the system's.
NavController
The single object that manages navigation for a NavHost. Call navigate(), popBackStack(), navigateUp() on it. One per NavHost. Expose it up via callbacks — never down to child composables directly. Obtain via findNavController() or rememberNavController().
NavGraph
The declaration of all destinations and the routes between them. In Era III it's an XML resource file. In Era IV it's a Kotlin DSL block inside NavHost { }. Graphs can nest — a sub-graph is itself a NavGraph with its own start destination and lifetime.
NavBackStackEntry
Each destination on the nav stack is a NavBackStackEntry. It is itself a ViewModelStoreOwner and a LifecycleOwner. ViewModels scoped to a NavGraph live in the graph's NavBackStackEntry's store — cleared only when the graph entry is popped.
popUpTo
Pops all destinations above the target before navigating to the new one. inclusive = true also pops the target itself. Essential for BottomNav tab switching (pop current tab's stack before switching) and post-login flows (clear the login stack after authentication).
navigateUp() vs popBackStack()
popBackStack() removes the top destination from Nav's stack — equivalent to pressing the hardware Back button. navigateUp() navigates Up in the logical hierarchy — it will cross task boundaries if needed (e.g. if you arrived via a deep link from another app). Use navigateUp() for the Toolbar Up arrow.
Predictive Back
Android 13+ feature that previews the destination behind the current screen as the user swipes Back, before confirming. Navigation 2.7+ supports it — the framework intercepts the Back swipe and animates the previous destination peeking in. Requires android:enableOnBackInvokedCallback="true" in the manifest.
The verdict
Android navigation didn't get worse.
Our expectations grew faster than the platform did.

Each era was the right tool for its time. Activity navigation was perfect for 2008's 3-screen apps. Fragments were the right answer when tablets demanded dual-pane layouts. Nav Component was the right answer when teams of 10 needed a shared navigation contract. Compose Navigation 2.8 is the right answer when type safety and refactorability are non-negotiable. The question was never "which is objectively best" — it was always "best for what context, what team size, what codebase age."