Every Angular navigation fires a sequence of 11 events — from NavigationStart through guard checks, resolve phases, and finally either NavigationEnd, NavigationCancel, or NavigationError. Subscribing to these events lights up the patterns that make a routed app feel polished: top-of-page loading bars, scroll restoration, analytics tracking, route-aware UI state. This lesson catalogs every event and the patterns that consume them.
This is lesson 5.4, following lesson 5.3 on functional guards. After this lesson you can build the polish layers most production apps need.
The 11 events in firing order
Every navigation goes through this sequence (assuming no failures):
1. NavigationStart ← navigation initiated
2. RouteConfigLoadStart ← about to load a lazy-loaded route (skipped if eager)
3. RouteConfigLoadEnd ← lazy route loaded (skipped if eager)
4. RoutesRecognized ← URL parsed into a route tree
5. GuardsCheckStart ← about to evaluate canActivate/canDeactivate
6. ChildActivationStart ← descending into a child route
7. ActivationStart ← about to activate a route
8. GuardsCheckEnd ← all guards passed
9. ResolveStart ← about to run resolvers
10. ResolveEnd ← all resolvers completed
11. ActivationEnd ← route activated
11b. ChildActivationEnd ← finished child activation
11c. NavigationEnd ← whole navigation finished successfully
Alternative endings:
NavigationCancel— a guard returned false, OR another navigation started, OR a redirect resolvedNavigationError— a resolver threw, a guard’s Promise rejected, the URL couldn’t be matchedNavigationSkipped(v15+) — same URL, no parameter change, no re-navigation
Understanding these by name is rarely necessary — you typically only care about Start + End + Cancel/Error. The full list matters when debugging why an event sequence does or doesn’t fire.
Subscribing to events
The Router.events Observable emits all events:
import { Component, inject, signal } from '@angular/core';
import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map } from 'rxjs';
@Component({
selector: 'app-loading-bar',
template: `@if (loading()) { <div class="bar"></div> }`,
styles: `.bar { position: fixed; top: 0; left: 0; right: 0; height: 3px; background: dodgerblue; animation: indet 1s linear infinite; }`,
})
export class LoadingBarComponent {
loading = toSignal(
inject(Router).events.pipe(
filter(e =>
e instanceof NavigationStart ||
e instanceof NavigationEnd ||
e instanceof NavigationCancel ||
e instanceof NavigationError
),
map(e => e instanceof NavigationStart),
),
{ initialValue: false },
);
}
The pattern: filter to the events you care about, map to your derived state, bridge to a signal. The component re-renders whenever loading flips.
Pattern 1: Top-of-page loading bar
Most-used router-events pattern. Shown above.
For a polished version with a minimum visible duration (so quick navigations don’t flash a bar that disappears immediately), add a small setTimeout:
constructor() {
inject(Router).events.subscribe(e => {
if (e instanceof NavigationStart) {
this.showTimer = setTimeout(() => this.loading.set(true), 150); // delay to avoid flash
} else if (e instanceof NavigationEnd || e instanceof NavigationCancel || e instanceof NavigationError) {
clearTimeout(this.showTimer);
this.loading.set(false);
}
});
}
The 150ms delay swallows fast navigations and shows the bar only on perceptibly-slow ones.
Pattern 2: Page-view tracking
Fire an analytics event on every successful navigation:
import { inject } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({...})
export class AnalyticsComponent {
private router = inject(Router);
constructor() {
this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
takeUntilDestroyed(),
).subscribe((e: NavigationEnd) => {
analytics.track('pageview', { path: e.urlAfterRedirects });
});
}
}
Use urlAfterRedirects (final URL after redirects) instead of url (what the user typed). Place this in the root App component so it fires for every navigation.
Pattern 3: Scroll restoration
The router provides scroll handling without manual events:
import { provideRouter, withInMemoryScrolling } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withInMemoryScrolling({
scrollPositionRestoration: 'enabled', // back button restores prior scroll
anchorScrolling: 'enabled', // #fragment scrolls to element
}))],
};
With both options enabled:
routerLink="/page"scrolls to top- Back button restores the previous scroll position
routerLink="/page" fragment="section"scrolls toid="section"on/page
For custom scroll behavior, listen to NavigationEnd and call window.scrollTo() yourself.
Pattern 4: View transitions
v17+ supports the View Transitions API for animated route changes:
import { provideRouter, withViewTransitions } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withViewTransitions())],
};
Browsers that support the View Transitions API will animate between routes (cross-fade by default; customize with CSS ::view-transition-old(root) / ::view-transition-new(root)). Older browsers fall back to instant transitions.
Pattern 5: Cancel-on-second-click
If the user clicks a navigation link twice quickly, the second click cancels the first navigation. Sometimes you want to show this differently:
this.router.events.pipe(
filter(e => e instanceof NavigationCancel),
).subscribe((e: NavigationCancel) => {
console.log('Cancelled:', e.reason); // e.g., "Navigation guard returned false"
});
The reason is human-readable for cancellation, useful for distinguishing “another navigation interrupted me” from “a guard blocked me.”
Pattern 6: Route-aware breadcrumbs
Built from the ActivatedRoute snapshot after each NavigationEnd:
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
breadcrumbs = signal<{ label: string; url: string }[]>([]);
constructor() {
inject(Router).events.pipe(filter(e => e instanceof NavigationEnd), takeUntilDestroyed())
.subscribe(() => this.breadcrumbs.set(this.buildCrumbs()));
}
private buildCrumbs(): { label: string; url: string }[] {
const crumbs: { label: string; url: string }[] = [];
let route: ActivatedRoute | null = this.route.root;
let url = '';
while (route) {
const segment = route.snapshot.url.map(s => s.path).join('/');
if (segment) url += '/' + segment;
const label = route.snapshot.data['breadcrumb'];
if (label) crumbs.push({ label, url });
route = route.firstChild;
}
return crumbs;
}
Declare breadcrumb labels via route data:
{ path: 'users', component: UserListPage, data: { breadcrumb: 'Users' } }
Reading the current URL reactively
For any signal that depends on the current URL:
import { Router, NavigationEnd } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs';
export class HeaderComponent {
private router = inject(Router);
currentUrl = toSignal(
this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
map((e: NavigationEnd) => e.urlAfterRedirects),
startWith(this.router.url),
),
);
isOnHomepage = computed(() => this.currentUrl() === '/');
}
The startWith gives you the current URL on initial render before any navigation has happened.
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Loading bar flashes on instant navigations | No delay before showing | Add setTimeout(150) to delay showing |
| Bar never disappears | Forgot to handle NavigationCancel/NavigationError | Subscribe to all three end events |
| Scroll restoration doesn’t work | Missing withInMemoryScrolling provider |
Add it to provideRouter |
#fragment doesn’t scroll |
Missing anchorScrolling: 'enabled' |
Both options needed |
| Analytics fires twice | Subscribed in two places | Centralize at the root component |
| Subscription leaks | Forgot takeUntilDestroyed() |
Always pipe through it |
What’s next
Lesson 5.5 covers data resolvers in depth — using resolve: to pre-fetch data before the route activates. Lesson 5.6 closes Module 5 with lazy loading and @defer blocks.
Module 6 picks up HttpClient — the request layer most apps lean on for resolvers and live data.
Try it yourself
A top-of-page loading bar with analytics tracking:
import { Component, inject, signal } from '@angular/core';
import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
import { filter, takeUntilDestroyed } from 'rxjs';
@Component({
selector: 'app-routing-effects',
template: `@if (loading()) { <div class="bar"></div> }`,
styles: `.bar { position: fixed; top: 0; left: 0; right: 0; height: 3px; background: dodgerblue; }`,
})
export class RoutingEffectsComponent {
loading = signal(false);
private router = inject(Router);
constructor() {
this.router.events.pipe(takeUntilDestroyed()).subscribe(e => {
if (e instanceof NavigationStart) {
this.loading.set(true);
} else if (e instanceof NavigationEnd) {
this.loading.set(false);
console.log('pageview', e.urlAfterRedirects); // your analytics call here
} else if (e instanceof NavigationCancel || e instanceof NavigationError) {
this.loading.set(false);
}
});
}
}
Drop into root, navigate around — bar appears + disappears, analytics fires on each completed page change.
NavigationEnd for link clicks, programmatic navigate() calls, AND back-button navigation. All three are legitimate pageviews. The one case where you want to filter is queryParamsHandling-driven URL updates (sort/filter changes that don’t really change the page) — those still fire NavigationEnd, and you can filter them by comparing e.url to a previous baseline.Lesson 5.5 picks up data resolvers and prefetching.

