skip to content

How would you write an Angular PreloadingStrategy that preloads the reports section only for signed-in admins, and what must it not be relied on for?

level: seniorimportance: should knowfreq 38%

answer

  1. preload(route, load) returns an Observable
  2. flag the route in data
  3. inject the auth state
  4. re-asked after each NavigationEnd
  5. chunks are not secrets

basics

~20 s

Implement PreloadingStrategy in a root-provided class whose preload() calls load() only when the route is flagged for admins and the injected auth state says the user is one, returning of(null) otherwise. It is a performance hint, never access control.

solid answer

~40 s

A custom strategy is an injectable class implementing `PreloadingStrategy`: `preload(route, load)` returns `load()` to download the chunk or `of(null)` to skip it. Mark the reports route with `data: { preloadFor: 'admin' }` and `inject()` the auth service, then load only when both agree; register the class with `withPreloading(AdminReportsPreloading)`, and give it `providedIn: 'root'` so the router can resolve it. Because the preloader walks unloaded routes again after every `NavigationEnd`, a user who signs in as admin later gets the chunk preloaded after their next navigation. It must not be treated as security: lazy chunks are public static files, anyone can request them, and `canMatch` does not stop preloading anyway. Access control belongs in guards for the UX and on the server for the data.

code

ts · 27 lines
ts
import { Injectable, inject } from '@angular/core';
import { PreloadingStrategy, Route, Routes, provideRouter, withPreloading } from '@angular/router';
import { Observable, catchError, of } from 'rxjs';
import { SessionStore } from './session-store';
import { adminGuard } from './admin-guard';

@Injectable({ providedIn: 'root' })
export class AdminReportsPreloading implements PreloadingStrategy {
  private readonly session = inject(SessionStore);

  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    const wanted = route.data?.['preloadFor'] === 'admin' && this.session.isAdmin();
    // swallow failures so one bad chunk request cannot stop all later preloading
    return wanted ? load().pipe(catchError(() => of(null))) : of(null);
  }
}

const routes: Routes = [
  {
    path: 'reports',
    canMatch: [adminGuard], // navigation UX only; the server still authorises every report request
    data: { preloadFor: 'admin' },
    loadChildren: () => import('./reports/reports.routes'),
  },
];

export const routerProviders = [provideRouter(routes, withPreloading(AdminReportsPreloading))];

go deeper

for a junior

Know that a custom preloading strategy is a class with a preload method that either calls load() or returns of(null).

for a middle

Explain how route data and an injected service drive the decision, and why the class must be provided for withPreloading to resolve it.

for a senior

Show that the strategy is re-asked after each NavigationEnd, and separate preloading, guards and server authorisation into their proper roles.

for a principal

Frame preloading policy per audience as a performance budget decision, and make sure nobody on the team treats chunk loading as access control.

## The contract `PreloadingStrategy` is an abstract class from `@angular/router` with one method: ```ts preload(route: Route, load: () => Observable<any>): Observable<any> ``` The router's preloader calls it for each lazy route that has not been loaded yet (`loadChildren` or `loadComponent`). The strategy decides by what it returns: - **`load()`** - start the download; the Observable emits when it completes; - **`of(null)`** (or `EMPTY`) - skip this route for now; - **`timer(ms).pipe(mergeMap(() => load()))`** - delay the download, for example until the first screen has settled. Because `withPreloading()` registers the class with `useExisting`, the class must be resolvable by the injector: give it `@Injectable({ providedIn: 'root' })`. Since the preloader instantiates it through DI, `inject()` works in its field initialisers. ## The reports-for-admins strategy Flag the route in its static data, and let the strategy combine that flag with the current user: ```ts @Injectable({ providedIn: 'root' }) export class AdminReportsPreloading implements PreloadingStrategy { private readonly session = inject(SessionStore); preload(route: Route, load: () => Observable<unknown>): Observable<unknown> { const forAdmins = route.data?.['preloadFor'] === 'admin'; return forAdmins && this.session.isAdmin() ? load() : of(null); } } ``` Routes without the flag are never preloaded by this strategy, so the rest of the app keeps on-demand loading. Keeping the decision in `data` rather than hard-coding paths lets feature teams opt routes in without touching the strategy. ## Timing: the strategy is asked more than once The preloader runs after **every** `NavigationEnd`, and each run revisits routes whose chunks are still not loaded. That matters for role-based logic: 1. On the first navigation a visitor is anonymous, so `preload()` returns `of(null)` for reports. 2. The user signs in as an admin; the app navigates to the dashboard. 3. After that `NavigationEnd`, the preloader asks again; now `isAdmin()` is `true` and the reports chunk downloads in the background. Once a chunk has loaded, the route is not offered to the strategy again. The reverse is also true: signing out does not unload code already downloaded. ## What it must not be relied on for | Belief | Reality | |---|---| | Non-admins cannot get the reports code | The chunk is a static file; anyone can request its URL or read it from a build | | `canMatch` on the route also blocks preloading | The preloader ignores `canMatch`; only the deprecated `canLoad` makes it skip a `loadChildren` route | | Skipping preloading protects report data | Data is protected only by the server's authorisation of each API call | The router's own source says it plainly: code splitting and lazy loading are separate from authorisation and should not be used as a security measure. The correct layering is: - the **preloading strategy** decides who benefits from a faster first click (performance); - a **`canMatch` or `canActivate` guard** keeps non-admins from navigating into reports (user experience); - the **server** rejects report API calls from non-admins (security). ## Handle load errors inside the strategy `PreloadAllModules` wraps each load in `catchError(() => of(null))`. A custom strategy should do the same. The preloader subscribes to its work without an error handler, so an error that escapes `preload()` (a chunk request failing on a flaky network, or an exception in your own role check) terminates the preloader's subscription: no further preloading happens for the rest of the session, and the error is reported as unhandled. Writing `load().pipe(catchError(() => of(null)))` keeps a failed preload local to that route, which will be offered again after the next `NavigationEnd`. ## Variations worth mentioning - Skip preloading on constrained networks by also checking a connection-quality service before calling `load()`. - Delay heavy sections with `timer()` so they do not compete with the dashboard's own requests. - Preload on intent (hovering a link) with a custom service that calls the route's loader directly; the built-in preloader has no notion of links on screen. - Keep the strategy side-effect free: it may be called many times for the same route.

  • Why does the strategy class need providedIn: 'root' when the docs example shows a bare @Injectable()?
    `withPreloading()` registers `PreloadingStrategy` with `useExisting` pointing at your class, so the injector must be able to create that class itself. A bare `@Injectable()` is not provided anywhere, so resolving it would fail unless you also add it to a providers array. `providedIn: 'root'` makes it self-providing.
  • How would you delay the reports preload until the dashboard's own requests have settled?
    Return a delayed load, for example `timer(2000).pipe(mergeMap(() => load()))`, or wait on a signal or Observable from the dashboard that reports it has finished loading and then call `load()`. The preloader subscribes to whatever you return, so any Observable that eventually calls `load()` works.

saying these in an interview costs you the question

  • Not preloading the chunk keeps its code secret from non-admins
  • A canMatch guard also stops the preloader from downloading the chunk
  • The strategy runs once at startup, so later sign-ins never preload
  • Returning false from preload() skips the route
  • Signing out unloads the preloaded reports chunk