Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Alec Irwin
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Alec Irwin

Creating A Proxy For Analytics Libraries In Order To Defer Loading And Parsing Overhead In Angular 7.2.13

By Ben Nadel on

When an Angular application loads, you want your "Time to First Interaction" (TTFI) to be as small as possible. This is the delay between the user's request for the application and the time at which the user can start interacting with said application. To keep this delay small, we employ strategies like code-splitting, dead code elimination, and the lazy-load of feature modules. But, one thing that I often see flying under the radar is the non-trivial delay caused by various Analytics and Tracking scripts. These scripts are usually loaded using snippets that run outside of the Application control-flow. Which means that they can run unchecked - draining HTTP request-pools and putting stress on the JavaScript runtime - delaying the TTFI of your Application. As such, I wanted to experiment with the idea of creating Analytics proxies that defer the loading, parsing, and processing of Analytics and Tracking libraries until well after the Angular 7.2.13 application has been bootstrapped.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

When it comes to analytics libraries, you may think that you are OK because your analytics scripts are loaded asynchronously in a non-blocking manner. But, the reality is, all requests are made over the same wire and then processed by the same CPUs. As such, even the asynchronous loading of non-block analytics libraries may still be draining resources that could otherwise be allocated for your user experience (UX).

Anecdotally, I have seen it make a difference! Meaning, I have seen the asynchronous loading of non-blocking analytics scripts create a negative impact on the perceived page-load time. But, it all depends on your particular context. Your mileage may vary.

And, to be clear, deferring the loading and parsing of Analytics scripts is not something to be taken lightly. While your users don't care about analytics and tracking at all, your Product Team certainly does. As such, deferring the loading of an Analytics script is a trade-off - not a cut-and-dry "win". That said, my job is to advocate for the user - not the Product Team. So, for me, losing some small faction of analytics data is not as meaningful as providing a more positive user experience (UX).

With that said, let's think about how we can defer the loading of Analytics scripts while still providing nice developer ergonomics. One technique that I've grown to love - in general - is the use of a Promise to represent a resource that may or may not be available yet. This kind of pattern is used a lot in the Node.js community, where things like Database Drivers may not yet be fully initialized:

var results = ( await getDB() ).query( "....." );

Here, the .getDB() method returns a Promise that will start queuing-up requests in memory until the underlying Database and connection pools are ready to be consumed. Then, once the database is available, all queued-up requests will naturally get flushed from memory and start executing queries.

Following this same kind of Promise-based pattern, we can create an Analytics proxy that executes its tracking methods after the underlying analytics library has been loaded:

( await getAnalyticsLibrary() ).identifyUser( .... )

Here, the .getAnalyticsLibrary() method returns a Promise that will start queuing-up tracking requests until the underlying library loads and resolves the Promise. Then, all of the subsequent .identifyUser() calls will be flushed from memory.

To get this working, we need to create a script-loading mechanism that replaces the HTML snippet that the developer would normally paste into the HEAD of the document. But, rather than just replacing the script-loading action, we're going to bake-in the idea of a delay such that the script won't even be injected - let alone loaded - until after some delay has been passed.

To do this, I've created a DelayedScriptLoader class that accepts a URL and a delay in milliseconds. And, rather than starting the delay immediately, this class waits for the script to be requested before it even initiates the lazy-loading workflow:

  • export class DelayedScriptLoader {
  •  
  • private delayInMilliseconds: number;
  • private scriptPromise: Promise<void> | null;
  • private urls: string[];
  •  
  • // I initialize the delayed script loader service.
  • constructor( urls: string[], delayInMilliseconds: number );
  • constructor( urls: string, delayInMilliseconds: number );
  • constructor( urls: any, delayInMilliseconds: number ) {
  •  
  • this.delayInMilliseconds = delayInMilliseconds;
  • this.scriptPromise = null;
  • this.urls = Array.isArray( urls )
  • ? urls
  • : [ urls ]
  • ;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I load the the underlying Script tags. Returns a Promise.
  • public load() : Promise<void> {
  •  
  • // If we've already configured the script request, just return it. This will
  • // naturally queue-up the requests until the script is resolved.
  • if ( this.scriptPromise ) {
  •  
  • return( this.scriptPromise );
  •  
  • }
  •  
  • // By using a Promise-based workflow to manage the deferred script loading,
  • // requests will naturally QUEUE-UP IN-MEMORY (not a concern) until the delay has
  • // passed and the remote-scripts have been loaded. In this case, we're not even
  • // going to load the remote-scripts until they are requested FOR THE FIRST TIME.
  • // Then, we will use they given delay, after which the in-memory queue will get
  • // flushed automatically - Promises rock!!
  • this.scriptPromise = this.delay( this.delayInMilliseconds )
  • .then(
  • () => {
  •  
  • var scriptPromises = this.urls.map(
  • ( url ) => {
  •  
  • return( this.loadScript( url ) );
  •  
  • }
  • );
  •  
  • return( Promise.all( scriptPromises ) );
  •  
  • }
  • )
  • .then(
  • () => {
  •  
  • // No-op to generate a Promise<void> from the Promise<Any[]>.
  •  
  • }
  • )
  • ;
  •  
  • return( this.scriptPromise );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I return a Promise that resolves after the given delay.
  • private delay( delayInMilliseconds: number ) : Promise<any> {
  •  
  • var promise = new Promise(
  • ( resolve ) => {
  •  
  • setTimeout( resolve, delayInMilliseconds );
  •  
  • }
  • );
  •  
  • return( promise );
  •  
  • }
  •  
  •  
  • // I inject a Script tag with the given URL into the head. Returns a Promise.
  • private loadScript( url: string ) : Promise<any> {
  •  
  • var promise = new Promise(
  • ( resolve, reject ) => {
  •  
  • var commentNode = document.createComment( " Script injected via DelayedScriptLoader. " );
  •  
  • var scriptNode = document.createElement( "script" );
  • scriptNode.type = "text/javascript";
  • scriptNode.onload = resolve;
  • scriptNode.onerror = reject;
  • scriptNode.src = url;
  •  
  • document.head.appendChild( commentNode );
  • document.head.appendChild( scriptNode );
  •  
  • }
  • );
  •  
  • return( promise );
  •  
  • }
  •  
  • }

As you can see, on the first request to .load(), the DelayedScriptLoader kicks off the delay. Then, once the delay is finished, it injects the Script tag(s), binds to the onload and onerror events, and eventually resolves a Promise once the underlying library has been loaded into the parent page. Subsequent calls to the .load() method then receive the existing Promise, which may or may not be resolved at the time of invocation.

Now that we have a mechanism to replace our HTML Script snippets, we can create our Proxy class that provides a strongly-typed interface and wraps the underlying calls to the Analytics library. To keep things simple, I've created a small class that provides an .identify() and a .track() method.

Note that this class doesn't use dependency-injection - it just hard-codes the URL being loaded. This makes it a bit harder to test; but, I am sure that if I took more time to consider testing, this could be reworked to be more "mockable" or configurable.

  • // Import the core angular services.
  • import { Injectable } from "@angular/core";
  •  
  • // Import the application components and services.
  • import { DelayedScriptLoader } from "./delayed-script-loader";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • // Since I don't have a Type Definition for this demo library, I'm just going to declare
  • // the interface here and then explicitly cast the global value when I reference it.
  • interface AnalyticsScript {
  • identify( userID: UserIdentifier, traits: UserTraits ) : void;
  • track( eventID: EventIdentifier, eventProperties: EventProperties ) : void;
  • }
  •  
  • export type UserIdentifier = string | number;
  •  
  • export interface UserTraits {
  • [ key: string ]: any;
  • }
  •  
  • export type EventIdentifier = string;
  •  
  • export interface EventProperties {
  • [ key: string ]: any;
  • }
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Injectable({
  • providedIn: "root"
  • })
  • export class AnalyticsService {
  •  
  • private scriptLoader: DelayedScriptLoader;
  •  
  • // I initialize the analytics service.
  • constructor() {
  •  
  • this.scriptLoader = new DelayedScriptLoader( "./analytics-service.js", ( 10 * 1000 ) );
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I identify the user to be associated with subsequent tracking events.
  • public identify( userID: UserIdentifier, traits: UserTraits ) : void {
  •  
  • this.run(
  • ( analytics ) => {
  •  
  • analytics.identify( userID, traits );
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I track the given event for the previously-identified user.
  • public track( eventID: EventIdentifier, eventProperties: EventProperties ) : void {
  •  
  • this.run(
  • ( analytics ) => {
  •  
  • analytics.track( eventID, eventProperties );
  •  
  • }
  • );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I return a Promise that resolves with the 3rd-party Analytics Script.
  • private async getScript() : Promise<AnalyticsScript> {
  •  
  • // CAUTION: For the sake of simplicity, I am not going to worry about the case in
  • // which the analytics scripts fails to load. Ideally, I might create some sort
  • // of "Null Object" version of the analytics API such that the rest of the code
  • // can run as expected with various no-op method implementations.
  • await this.scriptLoader.load();
  • // NOTE: Since I don't have an installed Type for this service, I'm just casting
  • // Window to ANY and then re-casting the global service that we know was just
  • // injected into the document HEAD.
  • return( ( window as any ).analytics as AnalyticsScript );
  •  
  • }
  •  
  •  
  • // I run the given callback after the remote analytics library has been loaded.
  • private run( callback: ( analytics: AnalyticsScript ) => void ) : void {
  •  
  • this.getScript()
  • .then( callback )
  • .catch(
  • ( error ) => {
  • // Swallow underlying analytics error - they are not important.
  • }
  • )
  • ;
  •  
  • }
  •  
  • }

As you can see, this class is just a light-weight proxy to the underlying library. Each analytics method calls .run(), which calls .getScript(), which just uses the Promise returned by the delayed-script-loader. And, because the DelayedScriptLoader class is Promise-based, so to is the AnalyticsService. As such, requests to the various analytics methods will naturally get queued-up in memory until the underlying analytics library has loaded and the DelayedScriptLoader Promise has been resolved.

Aren't Promises just ... the bee's knees?!

And, now that we have a light-weight Analytics Proxy class, we can consume it within our App component:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • // Import the application components and services.
  • import { AnalyticsService } from "./analytics.service";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • <p>
  • <a (click)="doThis()">Do This</a>
  • &mdash;
  • <a (click)="doThat()">Do That</a>
  • </p>
  • `
  • })
  • export class AppComponent {
  •  
  • private analyticsService: AnalyticsService;
  •  
  • // I initialize the app component.
  • constructor( analyticsService: AnalyticsService ) {
  •  
  • this.analyticsService = analyticsService;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I execute an action (that we're going to track).
  • public doThat() : void {
  •  
  • this.analyticsService.track(
  • "do.that",
  • {
  • now: Date.now()
  • }
  • );
  •  
  • }
  •  
  •  
  • // I execute an action (that we're going to track).
  • public doThis() : void {
  •  
  • this.analyticsService.track(
  • "do.this",
  • {
  • now: Date.now()
  • }
  • );
  •  
  • }
  •  
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • this.analyticsService.identify(
  • "bennadel",
  • {
  • group: "admin"
  • }
  • );
  •  
  • }
  •  
  • }

Notice that the App component doesn't care about whether or not the underlying analytics library is available - it only cares about the Proxy service. As such, it starts tracking analytics events right away. The events then get queued-up in memory, thanks to the Promise. And, when the underlying analytics library resolves (after the delay), all of these queued-up analytics requests get flushed out of memory and into the underlying analytics library.

It's hard to see this working in a screen-shot since it's timing-based. But, if we load this Angular application up in the browser and start clicking links, we'll see that nothing happens right away. Then, after 10-seconds, the analytics library is requested from the remote server. And, once it is loaded, we can see the analytics events being logged:


 
 
 

 
 Analytics library proxy in Angular for deferred loading. 
 
 
 

As you can see, there is a 10-second delay between when the application scripts have all been loaded an when our underlying Analytics library is loaded. And yet, we were able to call all of the tracking methods immediately after the Angular application was bootstrapped. This is because we are invoking those methods on the Proxy class which is, thanks to the inherent behavior of Promises, queuing those requests up in-memory until the analytics library is made available.

This isn't a perfect solution. Clearly, we lose some analytics data from the initialization of the app - things like the DOM Content Loaded event and metrics around AJAX request proxying that can't be queued-up in memory. But, using this technique may help the app load faster which may help deliver a more positive user experience.

Epilogue On Dependency Elimination

One of the nice side-effects of this approach is that it makes it extremely clear when a 3rd-party tracking library is no longer needed. That is to say, when all calls to the "proxy class" for said library are removed from the code, the 3rd-party library is implicitly removed because there's nothing that causes it to be loaded.



Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.