Angular Route Resolvers and Prefetching: Pre-Load Data Before the Route Activates [2026]

A typical Angular page flashes through three states: empty → loading spinner → loaded. Route resolvers eliminate the middle state — they fetch the data BEFORE the route activates, so the component renders fully populated on first paint. This lesson walks the ResolveFn API, prefetching strategies, and when resolvers are the right answer vs in-component loading.

This is lesson 5.5, following lesson 5.4 on router events. After this lesson you can choose deliberately between resolver-driven and component-driven data flows for any route.

The shape

A resolver is a function that returns a value (sync or async). Wired on a route’s resolve property:

import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { UserService } from './user.service';

export const userResolver: ResolveFn<User> = (route) => {
  const id = route.paramMap.get('id')!;
  return inject(UserService).load(id);
};

// route
{ path: 'users/:id', component: UserDetailPage, resolve: { user: userResolver } }

The resolver fires WHEN navigation reaches the resolve phase (after guards). The router waits for it to complete before activating the route. Until then, the previous page stays visible (or a loading bar fires from router events).

Reading the resolved data

Two ways — old and new.

Old: via ActivatedRoute

import { ActivatedRoute, inject } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';

export class UserDetailPage {
  private route = inject(ActivatedRoute);
  user = toSignal(this.route.data.pipe(map(d => d['user'] as User)));
}

Verbose, requires a pipe + map. Works in every Angular version.

New: via withComponentInputBinding()

// app.config.ts
provideRouter(routes, withComponentInputBinding())

// component
export class UserDetailPage {
  user = input.required<User>();   // ← resolved data lands here as input
}

The router writes each resolved key as a matching input. The component’s user input is set BEFORE first render. No pipe, no toSignal.

For new code: always use withComponentInputBinding.

What resolvers can return

Anything resolvable:

  • Plain valuereturn user
  • Promisereturn inject(UserService).load(id)
  • Observablereturn inject(UserService).load$(id) — first emission wins
  • null / undefined — valid resolved value

The router awaits the Promise / takes the FIRST value from the Observable. It does NOT subscribe to further emissions — for live data, the component still needs to subscribe itself.

Multiple resolvers per route

{
  path: 'users/:id',
  component: UserDetailPage,
  resolve: {
    user: userResolver,
    posts: userPostsResolver,
    org: organizationResolver,
  },
}

All three run in PARALLEL. Navigation waits for the slowest to complete. The component receives three inputs (user, posts, org) — one per key.

If any resolver throws or returns a rejected promise, the router fires NavigationError and the route does NOT activate.

Resolver error handling

The cleanest pattern: catch in the resolver, return a known-empty value, let the component handle the empty state:

export const userResolver: ResolveFn<User | null> = async (route) => {
  try {
    return await inject(UserService).load(route.paramMap.get('id')!);
  } catch {
    return null;   // component handles null with @if
  }
};

For cases where the navigation should be cancelled on resolve-failure, throw — the router will fire NavigationError:

export const requiredUserResolver: ResolveFn<User> = async (route) => {
  const user = await inject(UserService).load(route.paramMap.get('id')!);
  if (!user) throw new Error('User not found');
  return user;
};

The NavigationError can be caught by a global error handler that redirects to a 404 page.

Resolvers vs in-component fetching

A decision tree:

Pattern When to use
Resolver Data is ESSENTIAL for the page to render. No spinner state. Same data needed across all route variants.
resource() / httpResource() in component Loading state is acceptable. Data refetches based on signal changes inside the component.
Both Resolver loads the page-level data; in-component resources load detail panels lazily.

Real example: a user-detail page. The user (essential — page is meaningless without it) loads via resolver. Their posts (nice-to-have, can show a loading state) load via resource() inside the component.

// route
{
  path: 'users/:id',
  component: UserDetailPage,
  resolve: { user: userResolver },
}

// component
export class UserDetailPage {
  user = input.required<User>();   // from resolver

  // Loads after route activates — UI shows skeleton meanwhile
  posts = httpResource(() => ({
    url: `/api/users/${this.user().id}/posts`,
  }));
}

Resolvers reduce UX flicker; in-component resources keep pages quickly navigable.

Prefetching: speculative loads on hover

For an app where most users follow a few common paths, prefetch data when they HOVER on a link — by the time they click, the data is already cached:

import { Directive, inject, input, HostListener } from '@angular/core';
import { Router } from '@angular/router';
import { UserService } from './user.service';

@Directive({
  selector: '[prefetchUser]',
})
export class PrefetchUserDirective {
  userId = input.required<string>({ alias: 'prefetchUser' });
  private users = inject(UserService);

  @HostListener('mouseenter')
  prefetch() {
    this.users.load(this.userId());   // populates the service's cache
  }
}

Usage:

@for (u of users(); track u.id) {
  <a [routerLink]="['/users', u.id]" [prefetchUser]="u.id">{{ u.name }}</a>
}

When the user hovers, the service caches the response. When they click and the resolver fires, the service returns the cached value instantly — no network round-trip.

The service needs to be set up for caching (memoize by id). For HTTP responses, the cache can be the HttpClient’s own cached observable, or your own Map<id, User>.

A complete pattern: list-detail with prefetch

// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private cache = new Map<string, Promise<User>>();

  load(id: string): Promise<User> {
    if (!this.cache.has(id)) {
      this.cache.set(id, firstValueFrom(this.http.get<User>(`/api/users/${id}`)));
    }
    return this.cache.get(id)!;
  }
}

// resolver
export const userResolver: ResolveFn<User> = (route) =>
  inject(UserService).load(route.paramMap.get('id')!);

// prefetch directive (above)

User hovers over a list item → UserService.load(id) caches it → user clicks → resolver hits the cache → page renders immediately.

Refetching strategies

By default, navigating to the SAME URL doesn’t re-run resolvers. To force re-fetching:

{
  path: 'dashboard',
  component: DashboardPage,
  resolve: { data: dashboardResolver },
  runGuardsAndResolvers: 'always',
}

Now every navigation (even same-URL — e.g., clicking the nav link again) re-runs the resolver. Useful for time-sensitive routes.

For incremental refetching (background revalidate without blocking navigation), use httpResource() in the component instead of a resolver.

Common gotchas

Symptom Cause Fix
Resolver fires but component doesn’t see the data Forgot withComponentInputBinding() OR component is reading from route.data instead Add the provider + use input()
Two resolvers but only one runs One returned a synchronous value, the other a Promise — both finish, both work Check for typos in resolve: { key: ... }
Navigation hangs forever Resolver returned an Observable that never emits Take exactly one value — use firstValueFrom() or pipe through take(1)
Resolver throws but navigation still completes Caught the error and returned null — route activates with user = null Either re-throw to cancel, OR handle null in template
Same-URL nav doesn’t refetch Default runGuardsAndResolvers: 'paramsChange' Set 'always' for time-sensitive routes

What’s next

Lesson 5.6 closes Module 5 with lazy loading — loadComponent for whole route bundles, @defer for template-level lazy loading, and the prefetch strategies that pair with both.

Module 6 then picks up HttpClient — the layer that powers both resolvers and live data.

Try it yourself

A simple resolver pattern:

import { Component, input } from '@angular/core';
import { provideRouter, withComponentInputBinding, RouterOutlet, RouterLink, ResolveFn } from '@angular/router';
import { bootstrapApplication } from '@angular/platform-browser';

async function fakeFetch(id: string) {
  await new Promise(r => setTimeout(r, 400));   // simulate slow network
  return { id, name: 'User ' + id };
}

const userResolver: ResolveFn<{ id: string; name: string }> = (route) =>
  fakeFetch(route.paramMap.get('id')!);

@Component({
  template: `<h2>{{ user().name }}</h2><a routerLink="/">Back</a>`,
  imports: [RouterLink],
})
class DetailPage {
  user = input.required<{ id: string; name: string }>();
}

@Component({
  imports: [RouterLink],
  template: `
    <ul>
      <li><a [routerLink]="['/users', '1']">User 1</a></li>
      <li><a [routerLink]="['/users', '2']">User 2</a></li>
    </ul>
  `,
})
class ListPage {}

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `<router-outlet />`,
})
class App {}

bootstrapApplication(App, {
  providers: [provideRouter([
    { path: '', component: ListPage },
    { path: 'users/:id', component: DetailPage, resolve: { user: userResolver } },
  ], withComponentInputBinding())],
});

Click a user link — there’s a 400ms delay (the resolver awaits), then the detail page renders with user.name already populated. No loading state, no flash of empty content.

YouShould I use a resolver or just call the API in the component’s constructor?
YouFor data the page CAN’T render without (the user object on a user-detail page, the article body on a post page), use a resolver — no flicker, no “undefined” guard everywhere. For supplementary data (related posts, recommendations, sidebar widgets), use httpResource() in the component — the page renders fast, the secondary stuff streams in. Both have their place; pick based on whether the data is essential or supplementary.

Lesson 5.6 closes Module 5 with lazy loading.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *