skip to content

In an Angular app, how do you drive a top-of-page loading bar from the router, and why does a NavigationStart/NavigationEnd pair leave it stuck?

level: juniorimportance: must knowfreq 58%

answer

  1. not only NavigationEnd ends it
  2. cancel and error end it too
  3. currentNavigation() is null when idle
  4. computed() in the root component

basics

~10 s

Show the bar while Router.currentNavigation() is non-null, or start it on NavigationStart and stop it on NavigationEnd, NavigationCancel or NavigationError. Stopping only on NavigationEnd leaves it running after cancelled or failed navigations.

solid answer

~40 s

The simplest current approach, available since v20.2, is a `computed(() => this.router.currentNavigation() !== null)` in the root component: the router sets that signal when a navigation begins and clears it when the navigation finishes in any way. With `Router.events`, you set the bar on `NavigationStart` and clear it on **any** terminal event, `NavigationEnd`, `NavigationCancel` or `NavigationError` (and `NavigationSkipped` is harmless to include). The classic bug is clearing only on `NavigationEnd`: a guard that returns `false`, a resolver that fails, or a navigation superseded by a newer click never emits `NavigationEnd`, so the bar spins forever. Redirects also show up as a cancel followed by a new start, so the bar can blink unless you smooth it.

code

ts · 18 lines
ts
import { Component, computed, inject } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `
    @if (navigating()) {
      <div class="top-loading-bar" role="progressbar" aria-label="Loading page"></div>
    }
    <router-outlet />
  `,
})
export class App {
  private readonly router = inject(Router);
  // null when idle; cleared after end, cancel, error or abort
  protected readonly navigating = computed(() => this.router.currentNavigation() !== null);
}

go deeper

for a junior

Know that the bar must stop on cancel and error as well as end, and that currentNavigation() gives a ready-made signal.

for a middle

Explain which situations end a navigation without NavigationEnd, and how redirects show up as cancel plus a new start.

for a senior

Build a bar that never sticks and never flickers: signal or full terminal-event handling, a short show delay, and redirect awareness.

for a principal

Treat navigation feedback as a shared shell concern with one owner, and set a latency budget beyond which pages must stop blocking navigation.

## What the loading bar needs to know A top-of-page progress bar answers one question: **is a navigation in flight right now?** Angular navigations can take visible time: lazy chunks download, guards call the server, resolvers fetch data. During all of that the old page stays on screen, so without a bar the click looks ignored. ## Option 1: the `currentNavigation` signal (v20.2+) `Router.currentNavigation` is a signal holding the current `Navigation` object, or `null` when the router is idle. The router sets it as each navigation begins and resets it to `null` when that navigation finishes, whether it succeeded, was cancelled, failed or was aborted. ```ts protected readonly navigating = computed(() => this.router.currentNavigation() !== null); ``` With signals, the template re-renders when the value changes, which also works with the `OnPush` default of v22 and zoneless change detection. This replaces the deprecated `getCurrentNavigation()` method, which was a plain getter and could not be used reactively. ## Option 2: `Router.events` In older code, or when you need per-phase detail, derive the flag from events: ```ts readonly navigating = 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 essential part is the list of events that turn the bar **off**. ## Why start/end pairs get stuck Every navigation ends with exactly one of four events, and three of them are not `NavigationEnd`: | Situation | Terminal event | Bar if you only listen for `NavigationEnd` | |---|---|---| | guard returns `false` | `NavigationCancel` (`GuardRejected`) | stuck on | | user clicks another link before the first finishes | `NavigationCancel` (`SupersededByNewNavigation`) for the first, then the second ends normally | turns off only when the second ends | | resolver throws, chunk fails, no route matches | `NavigationError` | stuck on | | same URL, ignored | `NavigationSkipped`, with no `NavigationStart` | unaffected | The stuck bar is the classic bug. It is usually found after a guard starts redirecting unauthenticated users or an API starts failing. ## Redirects and flicker A guard redirect cancels the first navigation (`NavigationCancel`, code `Redirect`) and starts a new one. An events-based bar therefore turns off and on again between them. Two common refinements: - **Delay showing** the bar by 100-200 ms, so fast navigations never flash it; implement it with a small timer or an RxJS delay on the "on" value only. - **Ignore `Redirect` cancellations** when turning the bar off, since another navigation is about to start. ## Signal or events: which to use | Concern | `currentNavigation()` signal | `Router.events` | |---|---|---| | Available since | v20.2 | always | | Covers every ending | yes, reset to `null` on any finish | only if you list all terminal events | | Phase detail (guards, resolvers, lazy load) | read the `Navigation` object only | yes, per event | | Subscription teardown | none, it is a signal | needed in components (`takeUntilDestroyed`) | | Redirect blink | the old navigation is cleared and the new one set | cancel then start, visible unless smoothed | Phase detail is the main reason to keep an events-based variant. A bar can, for example, show a different label while a lazy chunk downloads (between `RouteConfigLoadStart` and `RouteConfigLoadEnd`) than while resolvers fetch data (between `ResolveStart` and `ResolveEnd`), which helps users and developers see where the time goes. For a plain on/off bar in current Angular, the signal is less code and cannot get stuck. ## Accessibility and placement - Put the bar in the root component, above the `<router-outlet>`, so it covers every route. - Give it `role="progressbar"` and an accessible label, and avoid moving focus to it. - Keep the logic in the root component or a small root service; per-page listeners duplicate it and leak if not torn down.

  • How would you avoid flashing the bar on navigations that finish in a few milliseconds?
    Delay only the transition to visible. With events, map `NavigationStart` to `true` and the terminal events to `false`, then use `switchMap` so `true` becomes `timer(150).pipe(map(() => true))` while `false` passes through immediately. A fast navigation emits `false` before the timer fires, so the bar never appears.
  • Why is currentNavigation() preferred over getCurrentNavigation() for this?
    `currentNavigation` is a signal, so a `computed()` or template that reads it updates automatically when navigation starts and stops, including under zoneless change detection. `getCurrentNavigation()` returns a snapshot value with no change notification, and it has been deprecated since v20.2 in favour of the signal.

The loading bar is like a 'train departing' light: switch it off only when the train arrives and it stays lit for every train cancelled or diverted; the reliable signal is the one that goes dark whenever the platform is empty, whatever happened to the train.

saying these in an interview costs you the question

  • Turning the bar off on NavigationEnd covers every navigation
  • A guard returning false emits NavigationEnd with no URL change
  • currentNavigation() stays set after a cancelled navigation
  • getCurrentNavigation() updates the template reactively
  • A redirect keeps the loading bar in one continuous navigation