While code splitting speeds main page load time, lazy loading slows initial navigation to other routes. Ensure fast navigations by preloading route bundles.
Code splitting is a well-known technique we can use to divide our application’s JavaScript bundle into smaller bundles.
Instead of loading the entire application’s JavaScript in the initial bundle, we only include what is needed for the main page. We then lazy load the other route bundles.
While code splitting improves the application load speed and allows the users to start interacting with the main page quickly, lazy loading can slow down the initial navigation to other routes.
To ensure fast navigations as well, we need to take a further step after code splitting.
The Angular Router lazy loads a route’s JavaScript bundle when the user visits that route. This means that when users visit a route for the first time, they have to wait for the browser to download, parse, compile and execute the JavaScript and then render the page.
The wait can be significantly longer on a slow network connection, leaving the user feeling frustrated. Chances are they will lose patience and abandon our site.
What can we do to make route navigations fast as well? ๐๐
The solution is quite simple really. ๐ We don’t have to wait until the user visits a route to load its bundle. Instead, we can start preloading the route bundles behind the scenes, after the application’s main page has loaded.
Preloading a route means the browser will already download, parse and compile the route’s bundle. Leaving the browser to only execute the JavaScript and render the page when the user visits the route. This vastly improves the page’s load speed. ๐
The Router service has a preloader, RouterPreloader, which runs in the background.
When a navigation ends, the preloader goes through the routes in our application’s route configuration and loads the route’s bundle if the following conditions are met:
PreloadingStrategy
registered with the Router service decides to load the route.Following is an example of a route configuration with a lazy-loaded route:
// Route Configuration
const routes: Routes = [{
path: 'feature-a',
loadChildren: () => import('./feature-a/feature-a.module').then(mod => mod.FeatureAModule),
}];
The preloader executes the callback function specified in the loadChildren
property to dynamically load the route’s module.
It is webpack that actually loads the module for us.
<link rel="prefetch"> vs import()
We often hear the terms preloading and prefetching when it comes to fetching resources (script, CSS, images) ahead of time.
The browser provides a prefetch mechanism, <link rel="prefetch">
, to download resources that are likely to be needed in future
navigations. The browser actually decides when the optimal time is to fetch the resource, taking into consideration the impact on the current page performance.
Additionally, there is a preload mechanism, <link rel="preload">
, to download resources that are needed for the current navigation.
It is easy to start thinking the Angular preloader maybe uses either prefetch or preload behind the scenes. Perhaps prefetch
as that is more suited for fetching resources that are likely to be needed in the next few navigations.
Also, when we read in webpack documentation about prefetching/preloading modules using the magic comments, we may start wondering if behind the scenes Angular adds the magic comments for us.
import(/* webpackPrefetch: true */ './feature-a/feature-a.module')
.then(mod => mod.FeatureAModule),
};
The result would be webpack adding a <link>
tag in the head of our document:
<head>
<link rel="prefetch" as="script" href="bundle-name.js">
</head>
However, this is not the case.
If we inspect our application, we can see (in the webpack/bootstrap
) that webpack adds a script
tag to the document body, thus telling the browser to download the bundle. Following is part of the code:
var script = document.createElement('script');
script.src = jsonpScriptSrc(chunkId);
document.head.appendChild(script);
The Angular preloader simply checks to see if it should call import()
for the dynamic/lazy routes that have not been loaded yet.
The route is dynamically imported after the current navigation has finished so it does not interfere with the current page load.
We can use canLoad
guards to prevent dynamically loading routes that a user does not have access to.
A canLoad
guard works fine to protect a lazy-loaded route, which is only loaded when the user visits the route.
However, a preloaded route is loaded before the user actually visits the route. As we saw earlier, the preloader loads the route bundles behind the scenes when a navigation has ended while the user is happily browsing the current route.
If the canLoad
guard decided against preloading the route, it may navigate the user to a different route. This would be confusing and frustrating for the user as they didn’t initiate the navigation.
To prevent this, the Angular preloader does not preload any routes that has a canLoad
guard on it.
We can use a canActivate
guard instead to protect the routes that we want to preload.
ThecanActivate
guard is run when the user actually visits the route and therefore it makes sense if the guard decides to navigate to another route when the user is not authorized to access that route.
Note: If a
canLoad
guard decides against loading the route, it either returnsfalse
to cancel the navigation or aUrlTree
to cancel the current navigation and start a new navigation to thatUrlTree
.
We mentioned earlier that the preloading strategy decides which modules to preload. The preloading strategy is represented by the PreloadingStrategy
abstract class.
It has a single method called preload()
which implements the logic for preloading a route.
abstract class PreloadingStrategy {
abstract preload(route: Route, load: () => Observable<any>): Observable<any>
}
When the current navigation ends, the preloader iterates over the routes in the Route Configuration and calls the preload()
method of the registered preloading strategy.
It passes in two arguments:
The preloading strategy is simply a service that implements the PreloadingStrategy class. We will look at how to create a preloading strategy in a bit.
To use the preloading strategy, we need to register it with the root Router service. The strategy applies to the whole application.
import { NgModule } from '@angular/core';
import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
const routes: Routes = []; // configure routes here
@NgModule({
imports: [
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }),
],
exports: [RouterModule],
})
export class AppRoutingModule {}
Once our application’s main page is loaded, we can start preloading the route bundles. The question is, what routes should we preload and when?
Let us first look at the preloading strategy Angular offers out of the box.
Angular has a PreloadAllModules
strategy that optimistically preloads all modules as soon as the main application page has finished loading.
Preloading all the bundles certainly makes navigating to the lazily loaded routes faster. However, if we have a large application with quite a few modules, preloading them all has issues:
In Angular, we can create a custom preloading strategy to provide the logic for which route modules to preload and when.
To create a custom preloading strategy, we create a service and implement the PreloadStrategy
abstract class.
@Injectable({
providedIn: 'root',
})
export class PreloadSelectedModules implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// logic for whether to preload this route module or not
}
}
The trick is to figure out which modules to preload.
It is time to get our feet wet and look at a demo. Let’s create a preloading strategy to address the problems highlighted earlier.
We will preload a route if:
export class PreloadSelectedModules implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
if (slowConnection() || saveData()) return EMPTY;
return selectedForPreload(route) ? load() : EMPTY;
}
}
data
PropertyAs mentioned previously, the preloader iterates over the route configuration and calls preload()
passing in the route. The Route object has
a data
property that lets us provide additional data for the route. We can use it to configure the routes we want to preload.
For example:
const routes: Routes = [
{
path: 'feature-a',
loadChildren: () => import('./feature-a/feature-a.module')
.then(mod => mod.FeatureAModule),
data: {
preload: true
}
}
];
In the preload()
method we can read the route’s data property to see if the route is configured for preloading.
export class PreloadSelectedModules implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
return selectedForPreload(route) ? load() : EMPTY;
}
selectedForPreload(route): boolean {
return route?.data?.preload;
}
}
The Network Information API provides us information about the user’s network connection and save data preference (for mobile users).
We can access it with:
const connection = navigator.connection;
If supported by the browser, navigator.connection
will return an object which contains information about the effective network connection. We are especially interested in the effectiveType
and saveData
properties:
The effectiveType property tells us the effective type of the user’s network connection—that is, whether it is a ‘slow-2g’, ‘2g’, ‘3g’ or ‘4g’. This is determined by recent round-trip times and downlink values. For users connecting with WiFi, the connection is matched to the effective network connection type.
Round-trip time (RTT)
is the time, measured in milliseconds, from when the browser sends a request to the server to when it receives a response from the server. downlink
represents the effective bandwidth measured in megabits
per second.
The code is pretty simple. ๐ We first check if navigator.connection
is supported. Then we can check if the user is on a slow network connection. You can decide whether to include '3g'
in the slow connections list or not.
slowConnection(): boolean {
const connection = navigator.connection;
const slowNetworks = ['slow-2g', '2g', '3g'];
return connection ? slowNetworks.includes(this.connection.effectiveType) : false;
}
saveData returns true if the user has the save data option turned on.
saveData():boolean {
const connection = navigator.connection;
return connection?.saveData;
}
Although the Network Information API is incredibly useful, it is an experimental technology and currently not supported by Safari and Safari on iOS. It is available in Firefox behind the dom.netinfo.enabled
preference (which needs to
be set to true).
Nonetheless, we can check to see if the connection information is available and, if available, use it when preloading bundles to be mindful of the users’ connection and save data mode preference.
In our demo, we manually selected the routes we wanted to prefetch. But we may not have insight into what routes to select. We could speculate on which route(s) the user is likely to visit next based on user behavior, like mouseover on a link, or based on the content of the page.
When a user hovers over a route-link, we could preload that route in anticipation that the user is likely to click on it.
If we want to be more certain, we can use mousedown
instead. There is still some time between mousedown
and mouseup
(making it a click).
InstantClick is an older JavaScript library that preloads resources on mouseover or mousedown.
We can implement our own version in Angular as shown by John Papa in his talk Preload Strategies.
Additionally, we can use the route configuration to blacklist routes that we don’t want to preload { data: { preload: none } }
and exclude them in our preload() method.
Another speculative preloading strategy is to preload the routes that are visible on the current page, as these are the routes the user is likely to visit.
We can make use of the ngx-quicklink
library that implements this for us!
ngx-quicklink
:
QuickLinkStrategy
for us, so we can just register it with our router serviceThe setup is super simple. Please refer to Angular quicklink Preloading Strategy written by Minko Gechev.
If we really want to take the guesswork out of deciding what route the user is likely to visit next, we can use Guess.js.
Guess.js:
prefetch
mechanism, which utilizes browser idle time to prefetch the modules; the browser prefetches the modules and stores them in the cache, ready for when the user navigates to that pageNote, unlike
ngx-quicklink
, Guess.js does not create a service that implements the PreloadingStrategy, which we then register with Angular’s Router service.
On top of all the awesomeness Guess.js provides, it is also easy to set up and has a small runtime. This is not surprising since it is created by thought leaders in performance, tooling and innovation.
You can read more about the background and an in-depth explanation about the math and technologies behind Guess.js in this article by Minko Gechev.
Code splitting and lazy loading improve our application load speed; however, the initial load speed is not all that matters. Lazy loading feature modules slows navigations, especially on slow network connections, as the user has to wait for the bundle to be downloaded and processed. Not to worry, though. We can preload the route modules for fast navigations.
The Angular Router provides a PreloadAllModules strategy. However, preloading too many bundles can slow the application down, cost the users in data and hinder the application’s interactivity. We need to be mindful of our users’ network connection and save data mode preference.
Angular also lets us create a custom preloading strategy. The challenge is to preload the routes the user is most likely to visit next. We can use speculative preloading and analytics data. We can also make use of libraries like ngx-quicklink
and Guess.js
.
You can see the full code here and play with the code. Here are some suggestions:
See the JavaScript bundles being loaded in Network
tab in the Chrome DevTools.
Throttle the network to a slower network to see that QuicklinkStrategy and PreloadSelectedModules do not preload modules on a slow network connection.
Ashnita is a frontend web developer who loves JavaScript and Angular. She is an organizer at GDGReading, a WomenTechmakers Ambassador and a mentor at freeCodeCampReading. Ashnita is passionate about learning and thinks that writing and sharing ideas are great ways of learning. Besides coding, she loves the outdoors and nature.