Skip to content

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 one idea to take away. A navigation is split into two phases: decide, which runs before the URL changes and is the only phase that can stop or reroute the navigation, and commit, which runs after the URL has changed and is where data is fetched and the page renders. Everything below is a consequence of that split.

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.

#StepWhat runsCan 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.

#StepWhat 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:

SignalEffectReported 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 exceptionFails 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---
LoadersIsReload (cache evicted)IsRevalidationIsPreload
Render✓ (old instances disposed first)✓ (content stays visible meanwhile)-
Scroll, focus & view transition---
Route lifecycleDisposing → fresh activation--
Errorsboundary + OnErrorboundary + OnErrorboundary + OnErrorswallowed

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