Skip to the solution
MediumFrontend10 min read

How do you optimize initial bundle size using Route Preloading with custom strategies?

690 views

The problem

Question presented to candidate: "Our initial bundle budget is tight, so every feature is lazy. Product wants the dashboard to open instantly, reports to be ready a few seconds after start-up, and settings only if the user shows interest. Design a custom preloading strategy for that, and explain what it does and does not do to the initial bundle."

What a strong answer should cover:

  • Lazy routes are what shrink the initial bundle; preloading only changes when lazy chunks download, never what is in the initial chunk (it adds a little router code).
  • A custom strategy is an @Injectable class implementing PreloadingStrategy.preload(route, load), registered with withPreloading(MyStrategy), typically driven by route.data flags.
  • Patterns: eager (return load()), delayed (timer(ms).pipe(mergeMap(load))), on intent (hover or viewport, via a shared service), skip (return EMPTY) for admin or rarely used routes.
  • The router runs one preload pass per NavigationEnd in sequence, so a strategy that waits indefinitely (for a hover that never comes) blocks later passes; fire-and-forget and return EMPTY instead.
  • Verify with ng build: the lazy chunk table, the initial total, and the Network panel in a real browser.

Clarifying questions expected:

  • "What is the bundle budget, and is it measured raw or compressed?"
  • "Do we need to respect data-saver or slow connections?"

Code / implementation expected: Yes β€” a strategy reading data.preload with eager, idle-delay and hover modes, plus a log of when each loader runs.

  • routing
  • performance

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read Β· 10 sections

Read the code

AngularCustom PreloadingStrategy with eager, idle and hover modes driven by route data
import { ApplicationConfig, Component, Injectable, inject } from "@angular/core";
import {
  NavigationEnd, PreloadingStrategy, Route, Router, RouterLink, RouterOutlet, Routes,
  provideRouter, withPreloading,
} from "@angular/router";
import { EMPTY, Observable, Subject, filter, mergeMap, take, timer } from "rxjs";

// Each lazy route says how eagerly it should be fetched:
//   data.preload = "eager" -> right after the first navigation
//   data.preload = "idle"  -> after data.delayMs
//   data.preload = "hover" -> when the user points at a link to it
//   no data                -> only when the user navigates there (normal lazy loading)
type PreloadMode = "eager" | "idle" | "hover";

@Injectable({ providedIn: "root" })
export class PreloadIntent {
  readonly hovered = new Subject<string>();
}

@Injectable({ providedIn: "root" })
export class SelectivePreloading implements PreloadingStrategy {
  private intent = inject(PreloadIntent);

  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    const mode = route.data?.["preload"] as PreloadMode | undefined;
    console.log(`  strategy asked about "${route.path}" (mode: ${mode ?? "none"})`);
    if (mode === "eager") return load();
    if (mode === "idle") return timer(route.data?.["delayMs"] ?? 0).pipe(mergeMap(() => load()));
    if (mode === "hover") {
      // Fire and forget: return EMPTY so this route does not hold up the
      // router's preload pass while it waits for a hover that may never come.
      this.intent.hovered.pipe(filter((p) => p === route.path), take(1), mergeMap(() => load())).subscribe();
      return EMPTY;
    }
    return EMPTY; // not preloaded
  }
}

@Component({ selector: "app-page", template: `<p>{{ title }}</p>` })
export class PageComponent { title = "page"; }

const lazy = (name: string) => () => {
  console.log(`  [${Math.round(performance.now() - t0)}ms] loader(${name}) called`);
  return Promise.resolve(PageComponent);
};
const t0 = performance.now();

export const routes: Routes = [
  { path: "", component: PageComponent },
  { path: "dashboard", loadComponent: lazy("dashboard"), data: { preload: "eager" } },
  { path: "reports", loadComponent: lazy("reports"), data: { preload: "idle", delayMs: 100 } },
  { path: "settings", loadComponent: lazy("settings"), data: { preload: "hover" } },
  { path: "audit-log", loadComponent: lazy("audit-log") },
];

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes, withPreloading(SelectivePreloading))],
};

@Component({
  selector: "app-root",
  imports: [RouterOutlet, RouterLink],
  template: `
    <a routerLink="/settings" (mouseenter)="intent.hovered.next('settings')">Settings</a>
    <router-outlet />
  `,
})
export class AppComponent {
  readonly router = inject(Router);
  readonly intent = inject(PreloadIntent);
  constructor() {
    this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe((e) =>
      console.log(`  [${Math.round(performance.now() - t0)}ms] NavigationEnd ${e.urlAfterRedirects}`));
  }
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 41 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

Signal inputs input() vs @Input() β€” what changes in Angular 17.1+?

Continue