A Guide to Angular 8's Differential Loading


TL;DR: Angular 8 is here! Learn all about one of its coolest new features: differential loading. Differential loading lets you serve up different bundles to different browsers and make your application even faster!

Angular 8 has only been out for about a week at the time I’m writing this, but there’s already been 17,000 “What’s New” articles published. Rather than throw my own take on the pile, I’ll refer you to the official Angular release announcement but here are the high points:

In this article, I want to dive into that last one: differential loading. What is that? Why does it matter? What do I need to do about it (if anything)?

What is Differential Loading?

Like most buzzwords in tech, the term “differential loading” is completely opaque about what it actually means. In a nutshell, differential loading means to send newer, flashier code to newer browsers and stable legacy code to legacy browsers. This generally translates into two things: modern syntax and polyfills. On a newer browser that supports recent syntax changes in JavaScript (such as arrow functions in ES2015 or async functions in ES2017), it’d be great to ship to code as-is with as few polyfills as possible to keep download time low. On legacy browsers, we’ll need both transpiled code and more polyfills to make things work. This way, newer browsers aren’t punished for legacy browsers by having to load a massive bundle, but legacy browsers aren't left in the dust.

Sounds cool, right? How does this work? Modern browsers have the ability to interpret a module type in the script HTML tag and to ignore a nomodule attribute. It looks like this:

<script src="fancyModernBundle.js" type="module"></script>
<script src="legacyBundle.js" nomodule></script>

When a modern browser (like a new version of Chrome) runs across this, it will know to only load fancyModernBundle. Likewise, when an older browser sees this, it won’t recognize the module type and only load legacyBundle.

Diagram of how differential loading in Angular 8 works (Source)

How You Normally Set Up Differential Loading

You’ve probably surmised by now that, in order to take advantage of differential loading, you’ll need to divide up your JavaScript code based on browser support. “Easy,” you think, “I’ll just plug the transpiled code into the nomodule tag and the original into the module tag.” Well, it’s not quite that simple. Unfortunately, not all browsers support all the same features of JavaScript. Beyond that, your users may not use the same browser you do. In fact, if your users are around the world, they may not even use browsers you’ve heard of — Opera Mini has over 100 million users in Africa!

It turns out that splitting up your bundle for differential loading is a combination of art and science. To pull this off, your bundler (like Webpack) and your transpiler or compiler (like Babel or TypeScript) must work together to divide up your code and add the correct polyfills in the right places. Luckily, there’s a configuration tool called browserslist that does nearly all of the heavy lifting for you.

Browserslist works with tools like Babel and is essentially a configuration tool that accepts query strings, which it runs against Can I Use data. Tools use Browserslist through either an entry in package.json:

// package.json
"browserslist": [ "last 1 version", "> 1%", "maintained node versions", "not dead"
]

Or in a separate config file called .browserslistrc:

// .browserslistrc
# Browsers that we support last 1 version
> 1%
maintained node versions
not dead

Both of these formats contain the same query:

  • last one version: the last version for each browser
  • >1%: browsers versions selected by global usage statistics
  • maintained node versions: all Node.js versions, which are still maintained by Node.js Foundation
  • not dead: excludes "dead" browsers, which are the same as those from a last 2 versions query, but with less than 0.5% in global usage statistics and without official support or updates for 24 months

Browserslist also has a defaults query that works for many people. They’ve got great explanations of the queries on their GitHub repo and there’s even a site called browserl.ist that lets you plug in a query and see a visual list of supported browsers.

Testing browserslist defaults queries in the browser

Differential Loading in Angular 8

Ordinarily, you’d need to configure your bundler and Babel or TypeScript to use browserslist to split up your code into the correct bundles. With version 8 of the Angular CLI, you actually get this set up right out of the gate with nothing for you to do! This is awesome because differential loading saves on average 7-20% in bundle size for Angular applications in modern browsers. In fact, angular.io saved over 40kB of its initial bundle size for modern browsers when the team enabled differential loading.

Want to check this out for yourself? It’s really easy!

Updating to Angular 8

First, make sure you have the latest version of the Angular CLI installed:

npm install -g @angular/cli

If you’d like to try this on an existing project, you can update it to Angular 8 by running:

ng update @angular/cli @angular/core

Check out the Angular Update Guide if your app requires more heavy lifting to update, like if it’s currently in version 6 or lower.

Otherwise, you can create a new application:

ng new diff-loading

Follow the prompts about routing and styling and then you’re good to go!

Configuration and building

If you open the new project in your favorite editor, you’ll notice a new file called browserslist. It contains the following by default:

// browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

You can adjust this list to match better with your own user demographics.

One very important note about this: See that not IE 9-11 part? If you need to support those browsers, you can remove the not, but it comes at a price. Those versions of Internet Explorer can’t take advantage of differential loading, so it will be disabled.

If you open tsconfig.json, you’ll see a particularly big change in the target property: it’s set to es2015 by default instead of es5.

Now that you’ve seen where the configuration lives, build the application with this command:

ng build --prod

After it’s done, open up dist/index.html and notice the script tags:

<script src=“runtime-es2015.858f8dd898b75fe86926.js” type=“module”></script>
<script src=“polyfills-es2015.e954256595c973372414.js” type=“module”></script>
<script src=“runtime-es5.741402d1d47331ce975c.js” nomodule></script>
<script src=“polyfills-es5.405730e5ac8f727bd7d7.js” nomodule></script>
<script src=“main-es2015.63808232910db02f6170.js” type=“module”></script>
<script src=“main-es5.2cc74ace5dd8b3ac2622.js” nomodule></script>

See all those module and nomodule instances? The Angular CLI did all that for you!

Differential Loading in Angular, in action

Now you just need to see it in action. Install a simple server like http-server:

npm install -g http-server

Then, within your project folder, serve up the project inside the dist folder:

http-server dist/diff-loading/

Open Chrome or another modern browser and head to the address of the server (on my machine it’s http://192.168.0.4:8080 but it may be different for you). Open up the network tab of the developer tools and refresh the page. You should see only the scripts that include es2015 in the name!

Angular differential loading success in Chrome in dev tools

Other settings

One quick note before I end this tutorial. If you change the different configurations, there are four different scenarios you could end up with:

  • A single ES5 build (differential loading disabled, target is es5)
  • Single ES5 build with conditional polyfills (differential loading enabled, target is es5)
  • Single ES2015 build (differential loading disabled, target is es2015)
  • Two builds with conditional polyfills (differential loading enabled, target is es2015)

To read more, check out the official documentation.

Conclusion

Angular 8 brings lots of great new stuff to Angular, including differential loading. I hope you’ve enjoyed this look into what differential loading is, how it works, and how to use it in Angular 8. See you next time!

Aside: Authenticate an Angular App and Node API with Auth0

We can protect our applications and APIs so that only authenticated users can access them. Let's explore how to do this with an Angular application and a Node API using Auth0. You can clone this sample app and API from the angular-auth0-aside repo on GitHub.

Auth0 login screen

Features

The sample Angular application and API has the following features:

  • Angular application generated with Angular CLI and served at http://localhost:4200
  • Authentication with auth0.js using a login page
  • Node server protected API route http://localhost:3001/api/dragons returns JSON data for authenticated GET requests
  • Angular app fetches data from API once user is authenticated with Auth0
  • Profile page requires authentication for access using route guards
  • Authentication service uses subjects to provide authentication and profile data to the app

Sign Up for Auth0

You'll need an Auth0 account to manage authentication. You can sign up for a free account here. Next, set up an Auth0 application and API so Auth0 can interface with an Angular app and Node API.

Set Up an Auth0 Application

  1. Go to your Auth0 Dashboard: Applications section and click the "+ Create Application" button.
  2. Name your new app and select "Single Page Web Applications".
  3. In the Settings for your new Auth0 app, add http://localhost:4200/callback to the Allowed Callback URLs.
  4. Add http://localhost:4200 to both the Allowed Web Origins and Allowed Logout URLs. Click the "Save Changes" button.
  5. If you'd like, you can set up some social connections. You can then enable them for your app in the Application options under the Connections tab. The example shown in the screenshot above uses username/password database, Facebook, Google, and Twitter.

Note: Set up your own social keys and do not leave social connections set to use Auth0 dev keys or you will encounter issues with token renewal.

Set Up an API

  1. Go to APIs in your Auth0 dashboard and click on the "Create API" button. Enter a name for the API. Set the Identifier to your API endpoint URL. In this example, this is http://localhost:3001/api/. The Signing Algorithm should be RS256.
  2. You can consult the Node.js example under the Quick Start tab in your new API's settings. We'll implement our Node API in this fashion, using Express, express-jwt, and jwks-rsa.

We're now ready to implement Auth0 authentication on both our Angular client and Node backend API.

Dependencies and Setup

The Angular app utilizes the Angular CLI. Make sure you have the CLI installed globally:

$ npm install -g @angular/cli

Once you've cloned the project on GitHub, install the Node dependencies for both the Angular app and the Node server by running the following commands in the root of your project folder:

$ npm install
$ cd server
$ npm install

The Node API is located in the /server folder at the root of our sample application.

Find the config.js.example file and remove the .example extension from the filename. Then open the file:

// server/config.js (formerly config.js.example)
module.exports = { CLIENT_DOMAIN: '[YOUR_AUTH0_DOMAIN]', // e.g., 'you.auth0.com' AUTH0_AUDIENCE: 'http://localhost:3001/api/'
};

Change the CLIENT_DOMAIN value to your full Auth0 domain and set the AUTH0_AUDIENCE to your audience (in this example, this is http://localhost:3001/api/). The /api/dragons route will be protected with express-jwt and jwks-rsa.

Note: To learn more about RS256 and JSON Web Key Set, read Navigating RS256 and JWKS.

Our API is now protected, so let's make sure that our Angular application can also interface with Auth0. To do this, we'll activate the src/environments/environment.ts.example file by deleting .example from the file extension. Then open the file and change the [YOUR_CLIENT_ID] and [YOUR_AUTH0_DOMAIN] strings to your Auth0 information:

// src/environments/environment.ts (formerly environment.ts.example)
...
export const environment = { production: false, auth: { CLIENT_ID: '[YOUR_CLIENT_ID]', CLIENT_DOMAIN: '[YOUR_AUTH0_DOMAIN]', // e.g., 'you.auth0.com' ... }
};

Our app and API are now set up. They can be served by running ng serve from the root folder and node server from the /server folder. The npm start command will run both at the same time for you by using concurrently.

With the Node API and Angular app running, let's take a look at how authentication is implemented.

Authentication Service

Authentication logic on the front end is handled with an AuthService authentication service: src/app/auth/auth.service.ts file. We'll step through this code below.

// src/app/auth/auth.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, bindNodeCallback, of } from 'rxjs';
import * as auth0 from 'auth0-js';
import { environment } from './../../environments/environment';
import { Router } from '@angular/router'; @Injectable()
export class AuthService { // Create Auth0 web auth instance // @TODO: Update environment variables and remove .example // extension in src/environments/environment.ts.example private _Auth0 = new auth0.WebAuth({ clientID: environment.auth.CLIENT_ID, domain: environment.auth.CLIENT_DOMAIN, responseType: 'id_token token', redirectUri: environment.auth.REDIRECT, audience: environment.auth.AUDIENCE, scope: 'openid profile email' }); // Track whether or not to renew token private _authFlag = 'isLoggedIn'; // Create stream for token token$: Observable<string>; // Create stream for user profile data userProfile$ = new BehaviorSubject<any>(null); // Authentication navigation onAuthSuccessUrl = '/'; onAuthFailureUrl = '/'; logoutUrl = environment.auth.LOGOUT_URL; // Create observable of Auth0 parseHash method to gather auth results parseHash$ = bindNodeCallback(this._Auth0.parseHash.bind(this._Auth0)); // Create observable of Auth0 checkSession method to // verify authorization server session and renew tokens checkSession$ = bindNodeCallback(this._Auth0.checkSession.bind(this._Auth0)); constructor(private router: Router) { } login() { this._Auth0.authorize(); } handleLoginCallback() { if (window.location.hash && !this.authenticated) { this.parseHash$().subscribe( authResult => { this._setAuth(authResult); window.location.hash = ''; this.router.navigate([this.onAuthSuccessUrl]); }, err => this._handleError(err) ) } } private _setAuth(authResult) { // Observable of token this.token$ = of(authResult.accessToken); // Emit value for user data subject this.userProfile$.next(authResult.idTokenPayload); // Set flag in local storage stating this app is logged in localStorage.setItem(this._authFlag, JSON.stringify(true)); } get authenticated(): boolean { return JSON.parse(localStorage.getItem(this._authFlag)); } renewAuth() { if (this.authenticated) { this.checkSession$({}).subscribe( authResult => this._setAuth(authResult), err => { localStorage.removeItem(this._authFlag); this.router.navigate([this.onAuthFailureUrl]); } ); } } logout() { // Set authentication status flag in local storage to false localStorage.setItem(this._authFlag, JSON.stringify(false)); // This does a refresh and redirects back to homepage // Make sure you have the logout URL in your Auth0 // Dashboard Application settings in Allowed Logout URLs this._Auth0.logout({ returnTo: this.logoutUrl, clientID: environment.auth.CLIENT_ID }); } private _handleError(err) { if (err.error_description) { console.error(`Error: ${err.error_description}`); } else { console.error(`Error: ${JSON.stringify(err)}`); } } }

This service uses the auth config variables from environment.ts to instantiate an auth0.js WebAuth instance. Next an _authFlag member is created, which is simply a flag that we can store in local storage. It tells us whether or not to attempt to renew tokens with the Auth0 authorization server (for example, after a full-page refresh or when returning to the app later). All it does is state, "This front-end application thinks this user is authenticated" and then allows us to apply logic based on that estimation and verify whether or not it's accurate.

We'll add and type a token$ observable, which will provide a stream of the access token string. This is for use with the token interceptor. We don't want our interceptor to utilize a stream that emits a default value without any useable values. We'll declare token$ in our _setAuth() method below, when the access token becomes available.

We will use an RxJS BehaviorSubject to provide a stream of the user profile that you can subscribe to anywhere in the app. We'll also store some paths for navigation so the app can easily determine where to send users when authentication succeeds, fails, or the user has logged out.

The next thing that we'll do is create observables of the auth0.js methods parseHash() (which allows us to extract authentication data from the hash upon login) and checkSession() (which allows us to acquire new tokens when a user has an existing session with the authorization server). Using observables with these methods allows us to easily publish authentication events and subscribe to them within our Angular application.

We'll create observables of the callbacks from these two auth0.js methods using using RxJS's bindNodeCallback. In order to preserve the scope of this, we'll bind() it like so:

bindNodeCallback(this._Auth0.parseHash.bind(this._Auth0))

The login() method authorizes the authentication request with Auth0 using the environment config variables. A login page will be shown to the user and they can then authenticate.

Note: If it's the user's first visit to our app and our callback is on localhost, they'll also be presented with a consent screen where they can grant access to our API. A first party client on a non-localhost domain would be highly trusted, so the consent dialog would not be presented in this case. You can modify this by editing your Auth0 Dashboard API Settings. Look for the "Allow Skipping User Consent" toggle.

We'll receive accessToken, expiresIn, and idTokenPayload in the URL hash from Auth0 when returning to our app after authenticating at the login page. The handleLoginCallback() method subscribes to the parseHash$() observable to stream authentication data (_setAuth()) by creating our token$ observable and emitting a value for the userProfile$ behavior subject. This way, any subscribed components in the app are informed that the token and user data has been updated. The _authFlag is also set to true and stored in local storage so if the user returns to the app later, we can check whether to ask the authorization server for a fresh token. Essentially, the flag serves to tell the authorization server, "This app thinks this user is authenticated. If they are, give me their data." We check the status of the flag in local storage with the accessor method authenticated.

Note: The user profile data takes the shape defined by OpenID standard claims.

The renewAuth() method, if the _authFlag is true, subscribes to the checkSession$() observable to ask the authorization server if the user is indeed authorized (we can pass arguments to this observable as we would to the auth0.js function). If they are, fresh authentication data is returned and we'll run the _setAuth() method to update the necessary authentication streams in our app. If the user is not authorized with Auth0, the _authFlag is removed and the user will be redirected to the URL we set as the authentication failure location.

Next, we have a logout() method that sets the _authFlag to false and logs out of the authentication session on Auth0's server. The Auth0 logout() method then redirects back to the location we set as our logoutUrl.

Once AuthService is provided in app.module.ts, its methods and properties can be used anywhere in our app, such as the home component.

Callback Component

The callback component is where the app is redirected after authentication. This component simply shows a loading message until the login process is completed. It executes the authentication service's handleLoginCallback() method to parse the hash and extract authentication information.

// src/app/callback/callback.component.ts
import { Component, OnInit } from '@angular/core';
import { AuthService } from '../auth/auth.service'; @Component({ selector: 'app-callback', template: `<div>Loading...</div>`, styles: []
})
export class CallbackComponent implements OnInit { constructor(private auth: AuthService) { } ngOnInit() { this.auth.handleLoginCallback(); } }

Making Authenticated API Requests

In order to make authenticated HTTP requests, it's necessary to add an Authorization header with the access token to our outgoing requests. Note that the api.service.ts file does not do this.

Instead, this functionality is in an HTTP interceptor service called token.interceptor.ts.

// src/app/auth/token.interceptor.ts
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { AuthService } from './auth.service';
import { Observable } from 'rxjs';
import { mergeMap } from 'rxjs/operators'; @Injectable()
export class InterceptorService implements HttpInterceptor { constructor(private auth: AuthService) { } intercept( req: HttpRequest<any>, next: HttpHandler ): Observable<HttpEvent<any>> { // @NOTE: If you have some endpoints that are public // and do not need Authorization header, implement logic // here to accommodate that and conditionally let public // requests pass through based on your requirements return this.auth.token$ .pipe( mergeMap(token => { if (token) { const tokenReq = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next.handle(tokenReq); } }) ); }
}

As mentioned above, we can return the token$ observable to acquire a token, then clone the outgoing HTTP request and attach an Authorization header before sending the request on its way.

The interceptor should be provided like so in the app-routing.module.ts file:

// src/app/app-routing.module.ts
...
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { InterceptorService } from './auth/token.interceptor';
... @NgModule({ imports: [...], providers: [ ..., { provide: HTTP_INTERCEPTORS, useClass: InterceptorService, multi: true } ], ...
})
export class AppRoutingModule {}

Note: We set multi to true because we could implement multiple interceptors, which would run in the order of declaration.

Final Touches: Route Guard and Profile Page

A profile page component can show an authenticated user's profile information. However, we only want this component to be accessible if the user is logged in.

With an authenticated API request and login/logout implemented in the Home component, the final touch is to protect our profile route from unauthorized access. The auth.guard.ts route guard can check authentication and activate routes conditionally. The guard is implemented on specific routes of our choosing in the app-routing.module.ts file like so:

// src/app/app-routing.module.ts
...
import { AuthGuard } from './auth/auth.guard';
... @NgModule({ imports: [ RouterModule.forRoot([ ..., { path: 'profile', component: ProfileComponent, canActivate: [ AuthGuard ] }, ... ]) ], providers: [ AuthGuard, ... ], ...
})
export class AppRoutingModule {}

To Do: Elegant Error Handling

Now that the primary functionality is there, you'll want to think about gracefully handling and reacting to errors. Some functionality will need to be implemented for this. The errors are there to react to, but you'll want to consider how you prefer to respond to them when they occur.

More Resources

That's it! We have an authenticated Node API and Angular application with login, logout, profile information, and a protected route. To learn more, check out the following resources: