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?
answer
- preload(route, load) returns an Observable
- flag the route in data
- inject the auth state
- re-asked after each NavigationEnd
- chunks are not secrets
basics
~20 sImplement 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 sA 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 linesimport { 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
Know that a custom preloading strategy is a class with a preload method that either calls load() or returns of(null).
Explain how route data and an injected service drive the decision, and why the class must be provided for withPreloading to resolve it.
Show that the strategy is re-asked after each NavigationEnd, and separate preloading, guards and server authorisation into their proper roles.
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