Angular Router Events: Loading Bars, Scroll Restoration, Analytics in 2026 [2026]

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 resolved
  • NavigationError — a resolver threw, a guard’s Promise rejected, the URL couldn’t be matched
  • NavigationSkipped (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 to id="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.

YouShould I track every NavigationEnd as a pageview, or only ones where the user navigated from a link?
YouEvery NavigationEnd — that’s what a pageview is. The router fires 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.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *