Lazy routes, resolvers, and guards can take seconds to load. The user needs to see that the transition is being handled — a global spinner over the app shell.
The problem it solves
The "navigating" state can't be described by a single event — the Router produces a whole family: NavigationStart, NavigationEnd, NavigationCancel, NavigationError. Set loading=true on Start and forget Cancel/Error, and the spinner hangs forever after a guard cancels navigation.
Operators and why they matter
filter — keep only the four event types we need (Start + three terminal ones), ignoring noise like RoutesRecognized.
map — turn the event type into a boolean: NavigationStart → true, everything else → false.
startWith(false) — the app isn't loading initially.
distinctUntilChanged — two Starts in a row (on a redirect) don't flicker the spinner.
Gotchas
Forgetting Cancel/Error is the most common mistake. The spinner hangs at true forever after a guard cancels.
scan with a loader counter is only needed for parallel loads. Router navigation is sequential — a counter is redundant.
A spinner in each page component is a bad idea. Lazy loading happens BEFORE the component is created, so the spinner won't show in time. Only in the app shell.
What you get
One global stream covers the whole navigation lifecycle — start, success, cancel, and error — without manual flags in components.