A resolver runs before the router activates a route and hands the result to the component, so the component starts life with its data already there. No loading spinner inside the page, no undefined checks in the template. That’s the appeal.
The cost is that the navigation waits. While the resolver runs, the user is still looking at the previous page, and nothing on screen says anything is happening unless you build that yourself. Resolvers also change what failure looks like: a failed request no longer shows an error inside the product page, because there is no product page yet. It cancels or fails the navigation itself.
Interviewers ask about resolvers to see whether you understand that trade-off. This note shows exactly what happens in each case, using a functional resolver on a product route, run through Angular 20.3’s RouterTestingHarness under Node. The behaviour is the same in Angular 19.
A functional resolver
Class-based resolvers implementing Resolve<T> still work, but functional resolvers are the recommended style in current Angular. A ResolveFn receives the route snapshot and runs in an injection context, so it can call inject():
export const productResolver: ResolveFn<Product> = (route) => {
const id = route.paramMap.get('id')!;
const api = inject(ProductApi);
const router = inject(Router);
return api.get(id).pipe(
catchError((err: HttpErrorResponse) =>
err.status === 404
? of(new RedirectCommand(router.parseUrl('/not-found')))
: throwError(() => err),
),
);
};
export const routes: Routes = [
{ path: 'products/:id', component: ProductPage, resolve: { product: productResolver } },
];The component reads it from ActivatedRoute.data, or, with withComponentInputBinding(), as an input with the same name as the resolve key:
export class ProductPage {
product = input.required<Product>();
}What the navigation looks like
For the test, the resolver returned a product after a 200 ms delay, returned a RedirectCommand for an unknown id, returned EMPTY for one id, and threw for another. Navigating through six URLs logged this (timestamps rounded to 50 ms):
0ms NavigationStart /products/1
0ms resolver runs for id=1
250ms Product constructed
250ms component input product=Keyboard
250ms NavigationEnd /products/1
250ms NavigationStart /products/1?tab=specs
250ms NavigationEnd /products/1?tab=specs (no resolver run)
250ms NavigationStart /products/2
250ms resolver runs for id=2
450ms NavigationEnd /products/2
450ms component input product=Mouse (same instance, new data)
450ms NavigationStart /products/99
450ms resolver runs for id=99
450ms NavigationCancel code=0 "Redirecting to "/not-found""
450ms NavigationEnd /not-found
450ms NavigationStart /products/empty
450ms NavigationCancel code=2 "At least one route resolver didn't emit any value."
router.url is still /not-found
450ms NavigationStart /products/boom
450ms NavigationError: 500 from /api/products/boom
navigateByUrl rejectedThere’s a lot packed into that, so take it case by case.
The wait is visible to users
The component wasn’t constructed until the data was ready, 200-odd milliseconds after the click. During that gap the old page was still on screen and the URL bar hadn’t changed yet. On a fast API that feels instant. On a slow one, users click again, which starts a new navigation and cancels the first.
If you use resolvers, give feedback at the app level. Listen to router.events and show a progress bar between NavigationStart and NavigationEnd, NavigationCancel or NavigationError. Without that, a resolver on a slow endpoint makes the app feel broken.
When resolvers run again
The second navigation only added ?tab=specs. No resolver ran. The default runGuardsAndResolvers setting is 'paramsChange', which reruns resolvers when path or matrix params change, but not query params. Going from product 1 to product 2 did rerun it, and the same component instance received the new product through its input (see the note on reused components and route parameters for why the instance is reused).
That second point catches people: if the component copies route.snapshot.data['product'] into a field in ngOnInit, it shows product 1 forever. Read resolved data as a signal input or from the data observable.
If the resolver depends on query params (a paginated list, say), set runGuardsAndResolvers: 'paramsOrQueryParamsChange' on the route, or 'always' if it should run on every navigation, including to the same URL.
Three ways a resolver can stop a navigation
Redirect. Returning a RedirectCommand (Angular 18+) cancels the current navigation and starts a new one to the target. In the log you see NavigationCancel with code 0 (redirect) followed by a complete navigation to /not-found. This is the clean way to handle “the product doesn’t exist”.
Empty. If the resolver’s observable completes without emitting, the router cancels the navigation with the message “At least one route resolver didn’t emit any value.” and stays where it was. The URL never changes. This often happens by accident: a filter() that rejects the value, or a catchError(() => EMPTY) added “to be safe”. The user clicks a link and nothing happens, with no error anywhere obvious.
Error. An error that escapes the resolver fails the navigation with NavigationError, and the promise from navigateByUrl rejects. The router also logs the error to the console. The user again stays on the old page. You can centralise handling with withNavigationErrorHandler() in provideRouter, which can return a RedirectCommand to an error page.
Gotcha
Note that only the first emitted value is used. A resolver returning a long-lived stream, such as a store selector that never completes, takes its first value and the router stops listening. Later changes won’t reach the component through
data.
When a resolver is the right tool
Resolvers fit data that is small, fast, and required for the page to make sense:
- the record a detail page is about, when you’d rather 404 than render an empty shell,
- data needed to compute the page title (the router’s
titleproperty can itself be a resolver), - server-side rendering, where blocking until the data is ready means the HTML arrives complete.
They’re a poor fit for slow or optional data: a dashboard with six panels, a comments list under an article, recommendations. Blocking navigation on all of that makes every click slow, and one failing endpoint stops the whole page from opening. Load those in the component with resource()/httpResource() or toSignal, show a skeleton for each part, and handle each failure locally.
A reasonable rule: resolve what the page can’t render without, load everything else.
Interview practice
A product detail route uses a resolver that calls /api/products/:id, /api/products/:id/reviews and /api/products/:id/recommendations with forkJoin. Users say clicking a product sometimes “does nothing for a few seconds” and sometimes “does nothing at all”. What’s going on, and how would you restructure it?
Answer
The resolver blocks navigation until all three requests finish, because forkJoin waits for the slowest. Recommendations are often the slowest endpoint, so the old page sits there for seconds with no feedback, which is the “does nothing for a few seconds” report.
“Does nothing at all” is a failure case. If any of the three requests errors, the forkJoin errors, the navigation fails with NavigationError, and the user stays on the list. If someone added catchError(() => EMPTY), it’s worse: the navigation is cancelled silently. Either way, a broken recommendations service stops people from seeing products.
I’d resolve only the product itself, because the page can’t render without it, and handle a 404 with a RedirectCommand to the not-found page. Reviews and recommendations would load in the component, each with its own loading and error state, using httpResource or rxResource keyed on the product id so they reload when the user moves to another product. I’d add an app-level progress bar on router events so the remaining resolver wait is visible. If they ask whether to drop the resolver entirely, I’d say that’s also fine for a client-rendered app; it’s a choice between “spinner in the page” and “wait on the old page”, and the resolver is most valuable with SSR or when an empty shell would be misleading.