How a navigation works
Every Brouter navigation runs the same ordered pipeline. Knowing which step runs when - and which phase it runs in - explains most of the router's behavior, from why a cancelled guard leaves no trace in the address bar to why a loader can't veto a navigation.
The shape of a navigation
A click on a <BrouterLink>, a brouter.Navigate(...) call, or
a Back/Forward press all enter the same pipeline. The decide phase hangs off Blazor's
RegisterLocationChangingHandler, so a veto there means the browser URL is never
written at all.
┌─────────────── DECIDE (URL has not moved yet) ───────────────┐ click / Navigate │ lazy assemblies → leave guards → OnNavigating → │ Back / Forward ──▶ match → enter guards → RedirectTo │ └──────────────────────────────┬───────────────────────────────┘ │ approved → the URL commits ┌──────────────────────────────┴───────────────────────────────┐ │ COMMIT: loaders → render → OnMatch / OnNavigated → │ │ scroll & focus → lifecycle arrivals │ └──────────────────────────────────────────────────────────────┘ cancelled or redirected anywhere in DECIDE → the navigation is prevented; the address bar never moved
Phase 1 · Decide - before the URL moves
The seven steps, in order
Each step runs to completion (awaited) before the next begins. Any step that cancels or redirects ends the phase immediately - later steps never run.
| # | Step | What runs | Can stop it? |
|---|---|---|---|
| 1 | OnNavigateAsync |
Lazy route loading. Assemblies returned here are scanned and registered before matching, so a deep link into a not-yet-loaded area matches within this same navigation. | no |
| 2 | Leave chain (leaf → root) | Per route: the component-level lock first (OnDeactivatingAsync when the route is being left, OnRenavigatingAsync when it stays matched with new parameters), then the route's LeaveGuard - only for routes actually being deactivated. |
yes |
| 3 | IBrouter.OnNavigating |
The global, app-wide hook. Runs once per navigation, after the outgoing page has had its say. | yes |
| 4 | Matching | Specificity picks one winning route. Nothing matched? OnNotFound fires, then either a preventive redirect to NotFoundUrl (so the unmatched URL never appears) or approval so the commit phase can render <NotFound> in place. |
n/a |
| 5 | Guard chain (root → leaf) |
Enter guards along the winning chain - a parent's guard protects every descendant, and runs first. | yes |
| 6 | RedirectTo |
A declarative redirect route hands off to its target. Guards run before this, so a redirect route can still be guarded. | reroutes |
| 7 | Approve | The navigation is allowed through and the browser URL commits. | - |
Note the direction change between steps 2 and 5: departures run leaf → root (the code closest to the state at risk gets the first veto) and arrivals run root → leaf (a parent authorizes before its children are considered). The same inversion applies to loaders and lifecycle callbacks in phase 2.
Why "preventive" matters
Most SPA routers let the URL change and then undo it if something objects. That produces a visible address-bar flicker, a history entry that has to be popped back off, and a Back button that no longer means what the user thinks. Because Brouter decides first, cancelling is a non-event: no flicker, no history to repair, and the page the user is on never unmounts. It is also what makes a genuine "you have unsaved changes" prompt possible - the dialog can hold the navigation open and the answer still decides the outcome.
Phase 2 · Commit - after the URL moves
From approved URL to rendered page
The commit phase re-selects the same winner (matching is a pure function of the URL and the route set, so it can't disagree with phase 1) and then does the work that produces pixels. Nothing here can veto the navigation - by this point the decision has been made.
| # | Step | What runs |
|---|---|---|
| 8 | Parameter binding | Route values are committed to the winner and propagated to every ancestor in the matched chain, so parent layouts see the parameters their own templates declare. |
| 9 | Loader chain (root → leaf) |
Loaders run sequentially by default - a parent's data is ready before its child's loader starts - or concurrently with ParallelLoaders. Each consults the stale-while-revalidate cache first, so a fresh hit skips the call entirely. While loaders are outstanding the router-level <Navigating> fragment is revealed. |
| 10 | Render | The new chain renders. With ViewTransitions enabled this render is wrapped in document.startViewTransition, which is what makes the animation cover the actual DOM swap. |
| 11 | OnMatch, then OnNavigated |
The router-component callback, then the global service hook - the place for page titles and analytics, now that the destination is certain. |
| 12 | Scroll & focus | Deferred until the render has actually landed, because fragment and focus selectors must resolve against the new page's DOM. Precedence: a resolved #fragment wins; else a remembered position on Back/Forward; else ScrollBehavior. |
| 13 | Lifecycle arrivals (root → leaf) | OnActivatedAsync for newly active routes, OnRenavigatedAsync for routes that stayed matched with new parameters. Departures (OnDeactivatedAsync, leaf → root) fire with Reason = Hidden for keep-alive routes or Disposing otherwise. |
Control flow & failure
Cancel, redirect, throw
Guards, locks and the global hook all steer the navigation through the same context object. The three exits behave differently on purpose:
| Signal | Effect | Reported as |
|---|---|---|
ctx.Cancel() | The navigation is prevented. The URL, the history stack and the rendered page are all untouched. | Cancelled |
ctx.Redirect(url) | The original navigation is prevented and a new one starts toward url. Redirect targets resolve route-relatively, and redirecting to the current target is a no-op, so loops can't form. | Redirected |
| An unhandled exception | Fails closed: the navigation is blocked rather than committed into a half-authorized state, and IBrouter.OnError is raised. Cancel and redirect are control flow, not errors - they never raise OnError. | Failed |
Exceptions in the commit phase - a loader that throws, a failing lifecycle callback -
take a different route: they surface in the nearest
error boundary (bubbling leaf → root,
with the router-level ErrorContent as the last resort) so the rest of the app
keeps rendering, and RetryAsync() can re-run the pipeline in place.
Supersession: the newest navigation wins
Users click faster than networks respond. Every await point in the pipeline is
version-checked, so when a second navigation starts while the first is still awaiting a
slow guard or loader, the older one stops where it is: its remaining steps are skipped, its
CancellationToken fires (so your own HttpClient calls abort too),
and it never renders, scrolls or fires OnNavigated on behalf of a page the user
already left. NavigateAsync reports that as Superseded.
This is why long-running guards and loaders should thread ctx.CancellationToken
through their async calls - it is the router telling you the answer is no longer wanted.
The same token is what lets a lock's confirmation dialog dismiss itself when the pending
navigation it was asking about gets replaced.
The other ways loaders run
Navigation vs reload vs revalidation vs preload
Not everything that runs a loader is a navigation. Three operations deliberately run a subset of the pipeline, and knowing which subset explains their guarantees.
| Step | Navigation | ReloadAsync() |
RevalidateAsync() |
PreloadAsync() |
|---|---|---|---|---|
| Leave guards & locks | ✓ | - (nothing is being left) | - | - |
| OnNavigating / OnNavigated | ✓ | - | - | - |
| Enter guards | ✓ | ✓ (matched from nothing) | - | - |
| URL changes | ✓ | - | - | - |
| Loaders | ✓ | ✓ IsReload (cache evicted) | ✓ IsRevalidation | ✓ IsPreload |
| Render | ✓ | ✓ (old instances disposed first) | ✓ (content stays visible meanwhile) | - |
| Scroll, focus & view transition | ✓ | - | - | - |
| Route lifecycle | ✓ | ✓ Disposing → fresh activation | - | - |
| Errors | boundary + OnError | boundary + OnError | boundary + OnError | swallowed |
Revalidation is "refresh what's on screen after a mutation": same URL, no
guards, no flash of pending UI, and it's a no-op if nothing in the current chain has a
loader. Reload is the heavy one next to it - the current chain's components
are disposed and recreated, so it is what you reach for when the page's derived
state is stale and not just its data (switching tenant, impersonating a user). It runs the
matching half of the pipeline because its chain is being built from nothing, which is why
enter guards and RedirectTo get a say and leave guards do not.
Preloading is speculative - it must never have side effects, which
is exactly why guards are skipped and failures are swallowed. Branch on
ctx.IsReload / ctx.IsRevalidation / ctx.IsPreload
when a loader should behave differently (skipping a telemetry ping on a preload, say).
A reload requested while a navigation is in flight - from the moment that navigation starts,
guards included - does nothing: that navigation is already rebuilding the page the user is
heading to. Neither does a reload with nothing committed (a not-found fallback or an error
boundary on screen): there is no chain to rebuild. The lighter
ClearKeepAlive(includeActive: true) throws the instances away without running
any of this pipeline at all - see
lifecycle & keep-alive.
The last case is the shortest: Navigate(url, forceLoad: true) skips the
pipeline entirely. The browser loads a new document, so the SPA process that would have run
the guards is replaced outright - which is also why NavigateAsync doesn't
offer it, as there would be nothing left alive to resolve the task.
Where to go from here
- Guards & navigation locks - steps 2, 3 and 5 in depth.
- Data loading - step 9, plus the cache that lets it be skipped.
- Lifecycle & keep-alive - step 13 and what retention changes about it.
- Scroll & focus - step 12's precedence rules.
- API reference - every parameter, option and context type named above.