Predictive Preloading Strategy for Your Angular Bundles

John Papa - May 31 '19 - - Dev Community

Users want fast apps. Getting your JavaScript bundles to your browser as quickly as possible and before your user needs them can make a huge and positive impact on their user experience. Knowing how you can improve that experience is important.

One way you can improve user experience with your Angular apps is to strategically decide which bundles to preload. You control when your bundles load and which bundles load. This is why you should explore choosing a built-in or creating your own custom Angular preload strategy.

In this series we'll explore a few of your options for preloading Angular bundles.

Here are the articles in this series

Scouting Ahead

The "on demand" strategy preloads one or more routes when a user performs a specific action. You decide which action will cause a route to preload. For example, you could set this up to preload a route while a user hovers over a button or menu item.

You can create the custom OnDemandPreloadService by creating a class that implements the PreloadingStrategy interface, and providing it in the root. Then you must implement the preload function and return the load() function when you want to tell Angular to preload the function.

Notice the preload function in the class OnDemandPreloadService examines the Observable preloadOnDemand$. It pipes the observable and uses the mergeMap RxJs operator to switch to a new Observable. This new Observable's value depends on the local preloadCheck function.

The preloadCheck function checks if the preloadOptions (which comes from the original Observable) has a routePath that matches a route that has the data.preload property set to true. So here we are opting some of the routes into preloading and leaving some routes to be loaded when they are requested explicitly.

@Injectable({ providedIn: 'root', deps: [OnDemandPreloadService] })
export class OnDemandPreloadStrategy implements PreloadingStrategy {
  private preloadOnDemand$: Observable<OnDemandPreloadOptions>;

  constructor(private preloadOnDemandService: OnDemandPreloadService) {
    this.preloadOnDemand$ = this.preloadOnDemandService.state;
  }

  preload(route: Route, load: () => Observable<any>): Observable<any> {
    return this.preloadOnDemand$.pipe(
      mergeMap(preloadOptions => {
        const shouldPreload = this.preloadCheck(route, preloadOptions);
        return shouldPreload ? load() : EMPTY;
      })
    );
  }

  private preloadCheck(route: Route, preloadOptions: OnDemandPreloadOptions) {
    return (
      route.data &&
      route.data['preload'] &&
      [route.path, '*'].includes(preloadOptions.routePath) &&
      preloadOptions.preload
    );
  }
}

Route Definitions

This strategy requires that you indicate which routes can be preloaded. You can do this by adding the data.preload property and set it to true in your route definition, as shown below.

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'heroes' },
  {
    path: 'dashboard',
    loadChildren: () =>
      import('app/dashboard/dashboard.module').then(m => m.DashboardModule),
    data: { preload: true }
  },
  {
    path: 'heroes',
    loadChildren: () =>
      import('app/heroes/heroes.module').then(m => m.HeroesModule),
    data: { preload: true }
  },
  {
    path: 'villains',
    loadChildren: () =>
      import('app/villains/villains.module').then(m => m.VillainsModule)
  },
  { path: '**', pathMatch: 'full', component: PageNotFoundComponent }
];

Notice that the dashboard and heroes routes both have the preload.data property set to true. However, the villains route does not have this property set. In this scenario the heroes and dashboard have preloading enabled, but the villains would only load when the user navigates to this route.

Setting the Custom OnDemandPreloadService

Then when setting up your RouterModule, pass the router options including the preloadingStrategy to the forRoot() function.

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      preloadingStrategy: OnDemandPreloadStrategy
    })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule {}

Deciding When to Preload

The missing piece here is the mechanism that you use to tell the app which route to preload and when to preload it. Notice the service OnDemandPreloadService in the code below. You can call this service's startPreload function and pass the route you wish to preload. The OnDemandPreloadService service then next's the subject (think of this like publishing or emitting a message). Then whoever or whatever listens to that message can act on it.

This is where the OnDemandPreloadStrategy strategy comes in, as it is listening.

export class OnDemandPreloadOptions {
  constructor(public routePath: string, public preload = true) {}
}

@Injectable({ providedIn: 'root' })
export class OnDemandPreloadService {
  private subject = new Subject<OnDemandPreloadOptions>();
  state = this.subject.asObservable();

  startPreload(routePath: string) {
    const message = new OnDemandPreloadOptions(routePath, true);
    this.subject.next(message);
  }
}

Bind to a Mouseover Event

Now your app is ready to preload a route when you decide to do it. You can try this by binding a DOM event such as mouseover and firing the OnDemandPreloadService's startPreload function.

<a
  [routerLink]="item.link"
  class="nav-link"
  (mouseover)="preloadBundle('heroes')"
  >heroes</a
>

Notice the following code accepts the route path and passes it along to the preloadOnDemandService.startPreload function.

preloadBundle(routePath) {
  this.preloadOnDemandService.startPreload(routePath);
}

All Together

Let's step back and follow how this all works.

  1. A user hovers over your anchor tag
  2. The mouseover binding calls a function in your component, passing the route path ('heroes' in this case)
  3. That code calls the PreloadOnDemandService service's startPreload, passing the route path to it
  4. The PreloadOnDemandService service next's the RxJS Subject, which is exposed as an Observable
  5. The OnDemandPreloadStrategy gets a handle on that Observable, and it knows when it "nexts"
  6. The OnDemandPreloadStrategy pipes it into mergeMap and evaluates the route for preloading
  7. If it decides to preload, the OnDemandPreloadStrategy returns a new Observable with the load() function
  8. If it decides not to preload, the OnDemandPreloadStrategy returns an Observable with the EMPTY observable (which does not preload)
  9. The Angular router listens to the response of the strategy's preload function and either preloads or not, accordingly.

Try It

After applying this strategy, rebuild and run your app with ng serve. Open your browser, open your developer tools, and go to http://localhost:4200. When you inspect the Network tab in your browser you will likely see none of your bundles already preloaded (except whichever route your navigated to by default, if that was lazy loaded).

Then hover over the HTML element where it fires with the mouseover event you bound. Check your network tab in your browser and you will see the bundle will be preloaded.

Deciding What Is Right For Your App

Now that you know how to create your own preload strategy such as OnDemandPreloadService, how do you evaluate if this is the right strategy for your app?

This is a more involved strategy for certain. Could it be beneficial to your users? Do your users often hover over search results before selecting them? Would that normally fire off a lazy loaded bundle? If so, perhaps this could give that preload a jumpstart.

If you can determine that your users' behavior and workflow often follows a specific path before loading a new bundle, then this strategy could be beneficial.

You can apply this to a number of scenarios such as hovering over an HTML element, clicking a button, or scrolling to a specific area of the screen.

In the end the decision is up to you. I recommend before choosing this options, or any preload strategy, that you test at various network speeds under various valid and common user workflows. This data will help you decide if this is the right strategy for you, or if another may be more beneficial for users of your app.

Resources

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .