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?
answer
- not only NavigationEnd ends it
- cancel and error end it too
- currentNavigation() is null when idle
- computed() in the root component
basics
~10 sShow 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 sThe 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 linesimport { 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
Know that the bar must stop on cancel and error as well as end, and that currentNavigation() gives a ready-made signal.
Explain which situations end a navigation without NavigationEnd, and how redirects show up as cancel plus a new start.
Build a bar that never sticks and never flickers: signal or full terminal-event handling, a short show delay, and redirect awareness.
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