Zygote
App architectureintermediate

Navigation

Navigation is a data structure, not a set of activity transitions. Once it is a graph, deep links, back behaviour and state restoration become properties instead of features.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

For most of Android's life, navigation meant "start another activity". That works until you need to restore a stack after process death, handle a deep link, or animate a transition between screens that are not activities. A navigation graph solves all three because it makes the destination list explicit.

TL;DR#

  • Navigation models screens as destinations in a graph, with routes and arguments.1
  • The back stack becomes a list of destinations, not a list of activity instances, so it can be saved and restored.
  • Deep links are declared on a destination, so the graph handles them the same way as an internal tap.
  • Compose's NavHost hosts destinations as composables; the activity count drops to one.
  • The graph is also the modularization boundary: a feature module can expose its destinations without exposing its screens.

Mental model#

Think of a subway map.

Stations are destinations; lines are back stacks; a transfer is a navigation action. The map exists independently of any train. You can start at any station (a deep link), and the route you took is recorded so you can go back.

Where the analogy breaks#

A subway map has no notion of "the same station, but with different arguments". A navigation route takes arguments, and the same destination with a different argument is a different back-stack entry. That is why routes are typed in modern Navigation, and why a string-concatenated route is a bug waiting to happen.

The big picture#

Loading diagram…
Text description of this diagram

A task-stack simulator that shows how destinations accumulate. Launching activities pushes instances onto task stacks; Back pops them. The same model applies to a navigation graph: each destination pushed by the NavController is an entry, and Back pops the graph. The difference is that graph entries are data — routes plus arguments — so they can be serialised, restored after process death, and reached by a deep link. The launch-mode and flag controls show why singleTask and CLEAR_TOP behave differently from an ordinary push.

The task stack is the system's model; the navigation graph is the app's model of the same idea, one level up.

Deep dive#

A graph#

@Serializable data object Home
@Serializable data class Detail(val noteId: String)
@Serializable data object Settings
 
@Composable
fun NotesNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController = navController, startDestination = Home) {
        composable<Home> {
            HomeRoute(
                onOpenNote = { id -> navController.navigate(Detail(id)) },
                onOpenSettings = { navController.navigate(Settings) },
            )
        }
        composable<Detail> { entry ->
            val detail: Detail = entry.toRoute()
            DetailRoute(noteId = detail.noteId, onBack = navController::popBackStack)
        }
        composable<Settings> {
            SettingsRoute(onBack = navController::popBackStack)
        }
    }
}

Three properties come free from this shape:

  1. Type-safe arguments. Detail(noteId) is a Kotlin object; a typo is a compile error, not a NullPointerException at runtime.
  2. Restorable back stack. The entries are serialisable, so the system can save and restore them after process death.
  3. A single activity. NavHost swaps composables; there is no activity transition per screen.1

Arguments and saved state#

@HiltViewModel
class DetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val repository: NotesRepository,
) : ViewModel() {
 
    private val noteId: String = checkNotNull(savedStateHandle.toRoute<Detail>().noteId)
 
    val note: StateFlow<Note?> = repository.observeNote(noteId)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}

The here is populated from the navigation arguments. That is why a ViewModel scoped to a destination survives process death with its arguments intact — the arguments were serialised as part of the back stack.

A deep link is a URI that resolves to a destination. Declared on the graph, it goes through exactly the same code path as an internal navigation call:

<nav-graph android:value="@navigation/notes_nav" />
composable<Detail>(
    deepLinks = listOf(navDeepLink<Detail>(basePath = "https://notes.example/note")),
) { entry ->
    // …
}

The manifest entry is what makes the OS aware of the link; without it, the link opens the app but the graph never sees it.2 And a verified App Link additionally checks assetlinks.json, which is what makes it open directly instead of via a chooser.2

Back, once more#

The navigation graph participates in predictive back automatically if the app opts in. What the graph adds is the ability to answer "what is the previous destination?" before the gesture completes — which is exactly what the system needs to animate the destination.

// Compose: intercept back for unsaved changes.
BackHandler(enabled = hasUnsavedChanges) {
    showDiscardDialog = true
}

BackHandler registers with the activity's OnBackPressedDispatcher scoped to the composition, so it is added and removed with the composable. That is the Compose answer to onBackPressed() overriding, and it composes correctly with the graph — the innermost enabled handler runs first.

The interesting problem for tablets and foldables: two destinations visible at once. The graph model handles it if you let the layout decide:

@Composable
fun NotesApp(windowSizeClass: WindowSizeClass) {
    val navController = rememberNavController()
 
    when (windowSizeClass.widthSizeClass) {
        WindowSizeClass.Compact -> {
            NavHost(navController, startDestination = Home) { /* single pane */ }
        }
        else -> {
            Row {
                NotesListPane(
                    selectedId = currentDetailId(navController),
                    onSelect = { navController.navigate(Detail(it)) },
                )
                NavHost(
                    navController = navController,
                    startDestination = DetailPlaceholder,
                    modifier = Modifier.weight(1f),
                ) { /* detail pane */ }
            }
        }
    }
}

The subtlety: in a two-pane layout, selecting an item should replace the detail entry rather than stack twenty entries. That is what popUpTo with inclusive is for.

navController.navigate(Detail(id)) {
    popUpTo<Detail> { inclusive = true }
    launchSingleTop = true
}

Transitions and the shared element#

Navigation owns the transition between destinations, and the API matters for perceived speed:

NavHost(
    navController = navController,
    startDestination = Home,
    enterTransition = { fadeIn(tween(220)) + slideInHorizontally { it / 6 } },
    exitTransition = { fadeOut(tween(160)) },
) { /* destinations */ }

Two rules keep transitions from costing frames:

  • Animate cheap properties. Fades and small translations are composited on the render thread; a transition that changes size forces a layout pass on every frame.
  • Keep durations short. Material guidance is in the low hundreds of milliseconds. A 500 ms transition feels slow even when it renders at 120 Hz.

Shared elements are the harder case. They require the two destinations to agree on a key, and they only work between destinations that are both composed. In practice, most shared-element animations are better implemented as a single destination that morphs its own layout.

Testing the graph#

@Test
fun backFromDetail_returnsToHome() {
    val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
    navController.navigatorProvider.addNavigator(ComposeNavigator())
 
    composeTestRule.setContent {
        NotesNavHost(navController)
    }
 
    composeTestRule.onNodeWithText("Shopping").performClick()
    composeTestRule.onNodeWithContentDescription("Back").performClick()
 
    assertEquals(Home::class, navController.currentBackStackEntry?.destination?.route?.let { Home::class })
}

A TestNavHostController runs the real graph without an activity, which catches the two most common navigation bugs: a destination that cannot be reached, and a Back that does not go where the graph claims. It is one of the few tests where asserting on the graph is more useful than asserting on the UI.

A feature module should expose destinations, not screens:

// :feature:notes — public surface
@Serializable data object NotesGraph
 
fun NavGraphBuilder.notesGraph(navController: NavController) {
    composable<NotesGraph> { NotesRoute(navController) }
    composable<Detail> { entry -> DetailRoute(entry.toRoute(), navController) }
}
 
// :app — assembles the graph
NavHost(navController, startDestination = Home) {
    notesGraph(navController)
    settingsGraph(navController)
}

The app module depends on :feature:notes; :feature:notes does not depend on :app and does not know about any other feature. Navigation actions that cross features are expressed as lambdas passed in, or as routes the app module wires together. That is the modularization pattern, and navigation is where it becomes concrete.

What the graph does not do#

A navigation graph does not decide whether the user may go somewhere. Authorisation belongs in the ViewModel or the repository — a destination that checks a permission in its composable checks it after the screen has already been built, which is a visible flash of the wrong thing.

The graph also does not own the argument's lifetime. An argument is serialised into the back-stack entry, which means it must be small and serialisable. Passing a large object through a route is the same mistake as putting it in a Bundle, with the same TransactionTooLargeException waiting at the end of it.

What the graph does own is the identity of a screen. Two entries with the same route but different arguments are different destinations, which is why the typed-route API exists and why string concatenation produces subtle bugs when an argument contains a slash.

A deep link is a public interface: once an app publishes https://notes.example/note/{id}, other apps, search results and users' bookmarks depend on it. That makes three things true which are easy to forget.

The route is versioned in practice. Changing note/{id} to note/{id}/comments breaks links that were already shared, so old links must keep resolving to something sensible.

The argument is untrusted. A link can carry any string; a noteId must be validated against the repository before it is used, and a link to a nonexistent note must show an error rather than a crash.

The link is a security boundary. An exported deep link can be triggered by any app, so it must not perform an action without confirmation — a link that deletes a note on open is a vulnerability.

Common mistakes#

MistakeConsequence
navigating in a composable bodyrepeated navigation on every recomposition
string routes with concatenated argsno type safety, encoding bugs
one activity per screen in a Compose apploses the graph's restoration
popBackStack() without checkingpops past the start destination
navigating from a ViewModelthe ViewModel now knows about the UI

The first one is the most common. Navigation is an effect:

// Wrong: navigate() on every recomposition.
@Composable
fun Done(check: Boolean) {
    if (check) navController.navigate(Home)
}
 
// Right: navigate once, as an effect keyed on the condition.
@Composable
fun Done(check: Boolean, onDone: () -> Unit) {
    LaunchedEffect(check) {
        if (check) onDone()
    }
}

The Done version with the lambda also keeps the composable testable and previewable, which is the same state-hoisting argument as everywhere else in this module.

How it evolved#

Architecture changes29 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
  • The first zygote-based app spawning model
41.6 Donut
  • Support for different screen densities via resource qualifiers
52.0 Eclair
  • First multi-core-friendly scheduling and graphics work
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
  • Support for x86 and ARMv7 optimisations
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
113.0 Honeycomb
  • Hardware acceleration for the View system (opt-in per app)
133.2 Honeycomb
  • New screen-size resource qualifiers for compatibility scaling
144.0 Ice Cream Sandwich
  • One system image for phones and tablets
  • Hardware acceleration on by default
164.1 Jelly Bean
  • Project Butter: vsync, triple buffering, Choreographer
  • RenderThread architecture begins
174.2 Jelly Bean
  • Multi-user support on tablets
  • SELinux enforcement begins in the kernel (behind the scenes)
184.3 Jelly Bean
  • SELinux enforcement rolled out more broadly
  • Binder changes for restricted profiles
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
  • Kernel moved under a tighter vendor control
204.4W KitKat (Wear)
  • First time the platform shipped for a new form factor
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
  • 64-bit ARM and x86 support
  • RenderThread and OpenGL ES 3.1
  • Project Volta and JobScheduler
  • SELinux fully enforcing
225.1 Lollipop
  • Device protection ties verified boot to Google accounts
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
  • Doze batches background work
  • Fingerprint HAL
  • Adoptable storage
247.0 Nougat
  • Project Treble groundwork
  • A/B seamless updates
  • Hybrid JIT + AOT compilation in ART
  • MediaServer split into sandboxed processes
268.0 Oreo
  • Project Treble: the vendor interface separates vendor and system code
  • Background execution limits
  • Broadcast and service restrictions
  • HIDL for HALs
278.1 Oreo
  • Android Go: a lighter configuration of the same platform
289 Pie
  • App Standby Buckets
  • Mainline groundwork
  • Non-SDK interface restrictions (greylists) begin
  • BiometricPrompt
2910
  • Project Mainline: APEX and updatable system modules
  • Scoped storage
  • Bubbles
  • Project Mainline starts shipping through Play
3011
  • GKI: the Generic Kernel Image splits the kernel into core + vendor modules
  • App hibernation
  • Package visibility filtering
  • Incremental app installs
3112
  • Bluetooth permissions split into the nearby-devices family
  • Exact alarm permissions
  • Foreground service launch restrictions
3212L
  • Window size classes and activity embedding
3313 Tiramisu
  • Granular media permissions replace READ_EXTERNAL_STORAGE
  • The notification permission becomes a runtime permission
  • Predictive back gesture
3414 Upside Down Cake
  • Foreground service types are mandatory
  • Selected photos access via READ_MEDIA_VISUAL_USER_SELECTED
3515 Vanilla Ice Cream
  • 16 KB page size support for native libraries
  • Edge-to-edge is enforced when targeting 35
3616 Baklava
  • The release moves to Q2 (June) with a Q4 feature drop
  • Two SDK releases per year starting with 36
  • Desktop windowing arrives in developer options then stable
3717 Cinnamon Bun
  • Strict per-device app memory limits are enforced by the system
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector
  • A lock-free MessageQueue for apps targeting API 37
  • Compose-first: new platform and Jetpack APIs target Compose only; View libraries enter maintenance mode
  • Hybrid APK signing (v3.2) combines classical and ML-DSA post-quantum signatures
  • Native library dynamic code loading must be read-only

Three stages: activities and intents (2008), the (2018), and Compose's NavHost plus type-safe routes (2021 onward). Each stage moved more of the navigation model out of the OS and into the app, which is what made deep links, restoration and multi-pane layouts tractable.

Under the hood#

Curiosity corner#

  • The back stack is saved as a Bundle. That is why navigation arguments must be serialisable, and why @Serializable routes are the modern default.
  • launchSingleTop is the graph's version of singleTop. Same idea, different layer.
  • A NavHost is a composable, which means two NavHosts in a two-pane layout are two independent back stacks in one activity.

Try it#

See the navigation graph of a running app

adb shell dumpsys activity top | grep -i "nav"

Trigger a deep link

adb shell am start -a android.intent.action.VIEW -d "https://notes.example/note/42"

Check yourself#

Check yourself

1 / 12

Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsナビゲーション | App architecturedeveloper.android.comaccessed 2026-10-27↩
  2. docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
  3. docsJetpackdeveloper.android.comaccessed 2026-10-27↩
  4. docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩
  5. docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩
  6. docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩