How to improve Initial Load Performance with Angular 17’s Deferrable Views

Besides some minor updates concerning Server Side Rendering (including things as making the great new Hydration feature stable, renaming the universal package to @angular/ssr and adding SSR support to the CLI's ng new command) the biggest upgrade for us performance enthusiasts in Angular 17 is definitely the new block template syntax including the defer block feature. By the way, the new template syntax is also beneficial for the runtime performance - we'll cover that topic in another blog post soon 😏

Until Angular 16 it was a bit complicated to dynamically lazy load (now called defer) components, without having to use the Angular router's loadChildren (for a module or an array of components) or loadComponent (for just one standalone component). Since Angular 13 we didn't need a ComponentFactoryResolver to create the component anymore, but we still had to use a ViewContainerRef and something like an async / await block to dynamically load, create and insert the component into the DOM.

With @defer we now have an elegant and intuitive option to delay the loading of components until they are needed. This is especially useful for components that are not visible on the initial screen (so-called above-the-fold) of our web app. The @defer feature has the largest impact if we can use it for heavy components that include large third party packages like feature-rich tables, charts or some export functionality (e.g. PDF generator), because those packages can then also be deferred and thus excluded from the eagerly loaded main bundle.

Why is Initial Load Performance so important?

We recently blogged about Why Initial Load Performance is so important, focussing on SSR.
Deferrable Views, also known as @defer blocks, are a lightweight innovation to reduce the initial bundle size and thus further improve the Initial Load Performance of our Angular app.

We also wrote about how to measure the Initial Load Performance.
Deferring some of your components should specifically improve First Contentful Paint (FCP), Largest Contentful Paint (LCP) and even Time to First Byte (TTFB).
However, be careful not to increase the Cumulative Layout Shift (CLS) by deferring components that are visible on the initial above-the-fold screen.
You can achieve this by using placeholders (see below for @placeholder and @loading) with fixed width and height properties - just like you'd do for lazy loading images with the help of NgOptimizeImage.

How to dynamically load components with Angular 13 - 16

In my Performance Workshop 🚀 I always show(ed) how to dynamically load components with Angular 13 - 16.

Firstly, we need to get a ViewContainerRef in the components view template. Since we don't want to create an HTML element, we use an ng-container:

<ng-container #viewContainer />

Important note: Since Angular 15.1.0 we can use self-closing tags in Angular - also for components without content.

Secondly, we need to fetch the ViewContainerRef via a @ViewChild using the template reference #viewContainer in the component class:

@ViewChild('viewContainer', { read: ViewContainerRef }) viewContainerRef!: ViewContainerRef;

Thirdly, we can use async / await to dynamically import a component and insert it into the DOM:

async ngAfterViewInit() {
  const { LazyComponent } = await import('./lazy/lazy.component');
  const lazyComponentRef = this.viewContainerRef.createComponent(LazyComponent);
}

As a result the Angular Compiler will create a new chunk for the LazyComponent and the app (well, the browser) will load the bundle on demand.

While this is still a working approach, it is a bit heavyweight and less powerful than using the new @defer syntax.

Important note: To fully support deferring our LazyComponent must be a standalone component.

How to dynamically load components with Angular 17's @defer

With Angular 17 we can use the fancy @defer syntax. The syntax is very similar to the new @if and much, much easier to use:

@defer {
  <aa-lazy-component />
}

That's all there is to it 🥳 - the Angular Compiler will again create a new chunk for the LazyComponent and the app (again, the browser) will load the bundle on demand.

But that's not the end of the story (nor this post 😉). We can additionally leverage the control of the lazy loading process by using built-in and even our own triggers.

Triggers

The @defer block in the provided context is used to replace placeholder content with lazily loaded content.

Two options for triggering this swap are specified: on and when.

on

on specifies built-in trigger conditions using events like interaction or viewport.

@defer (on viewport) {
  <aa-lazy-component />
}

A list of on events provided in Angular 17:

  • on idle triggers once the browser has reached an idle state (detected using the requestIdleCallback API).
    This is the default behavior with a defer block, so no need to write it explicitly.

    @defer {
        <aa-lazy-component />
    }
  • on viewport triggers when the specified content enters the viewport (using the IntersectionObserver API).
    This could be the placeholder content.

    @defer (on viewport) {
        <aa-lazy-component />
    } @placeholder {
        <img width="420" height="420" alt="lazy placeholder" src="placeholder.avif" />
    }
    • Alternatively, you can specify a template reference #variable.
    <div #viewportVariable>Hello!</div>
    
    @defer (on viewport(viewportVariable)) {
        <aa-lazy-component />
    }
  • on interaction triggers when the user interacts with the specified element through click or keydown events.

    @defer (on interaction) {
        <aa-lazy-component />
    }
    • Alternatively, you can also specify a template reference #variable.
    <button #interactionVariable>Hello!</button>
    
    @defer (on interaction(interactionVariable)) {
        <aa-lazy-component />
    }
  • on hover triggers on hover (actually mouseenter or focusin).

    @defer (on hover) {
        <aa-lazy-component />
    }
    • Alternatively, you can again specify a template reference #variable.
    <div #hoverVariable>Hello!</div>
    
    @defer (on viewport(hoverVariable)) {
        <aa-lazy-component />
    }
  • on immediate triggers the deferred load immediately. Once the client has finished rendering, the defer chunk will start fetching right away. This is similar to a setTimeout with a timeout of 0.

    @defer (on immediate) {
        <aa-lazy-component />
    }
  • on timer triggers after a timeout in ms or s, working like setTimeout once the client has finished rendering.

    @defer (on timer(4200ms)) {
        <aa-lazy-component />
    }

when

when specifies an imperative condition as an expression that returns a boolean. If the condition returns to false, the swap is not reverted; it is a one-time operation.

class WhenDemoComponent {
  condition = false;

  trigger() {
    this.condition = true;
  }
}
@defer (when condition) {
  <aa-lazy-component />
}

Multiple when and on triggers can be used together in a statement. They are always OR conditions so the swap occurs if either condition is met.

Additional features of the defer block

Beyond easily specifying triggers, the @defer block offers the following useful features:

prefetch

@defer allows to specify conditions when prefetching of the dependencies should be triggered.

It works similarly to the main defer conditions, and accepts when and/or on to declare the trigger.

In this example, the prefetching starts when a browser becomes idle.

@defer (on viewport; prefetch on idle) {
  <aa-lazy-component />
}

@placeholder

A placeholder content to be displayed until the @defer block is loading.

By default, defer blocks remain inactive until triggered. The optional @placeholder block displays content before activation, which is then replaced by the main content after loading. Note that placeholder block dependencies are eagerly loaded, and various content types are supported.

Important note: When rendering an application on the server (either using SSR or prerendering, now called SSG), @defer blocks will ignore triggers and always render the @placeholder (or nothing if no placeholder is specified).

@defer (on viewport; prefetch on idle) {
  <aa-lazy-component />
} @placeholder (minimum 500ms) {
  <img width="420" height="420" alt="lazy placeholder" src="placeholder.avif" />
}

The @placeholder block accepts an optional minimum parameter to specify the amount of time that it should be shown.

@loading

A loading content to be shown to users while the @defer block is loading.

The optional @loading allows you to declare content that will be shown during the loading of any deferred dependencies. For example, you could show a loading spinner. Similar to @placeholder, the dependencies of the @loading block are eagerly loaded.

@defer (on viewport; prefetch on idle) {
  <aa-lazy-component />
} @placeholder (minimum 500ms) {
  <img width="420" height="420" alt="lazy component placeholder" src="placeholder.avif" />
} @loading (after 500ms; minimum 1s) {
  <img width="420" height="420" alt="lazy is loading spinner" src="spinner.avif" />
}

Just like @placeholder, after and minimum exist to prevent fast flickering.

@error

An error content to be displayed in case the @defer block encounters an error during loading.

The optional @error allows you to declare content that will be shown if deferred loading fails.

@defer (on viewport; prefetch on idle) {
  <aa-lazy-component />
} @placeholder (minimum 500ms) {
  <img width="420" height="420" alt="lazy component placeholder" src="placeholder.avif" />
} @loading (after 500ms; minimum 1s) {
  <img width="420" height="420" alt="lazy is loading spinner" src="spinner.avif" />
} @error {
  <p>Why do I exist?</p>
}

Automated control-flow migration

I also want to mention that one of the Angular teams' goals of the built-in control flow was to enable completely automated migration. Give it a try in your app:

ng update

ng g @angular/core:control-flow

And now make sure to try out the new @defer block feature 👏

Conclusion

Angular 17's introduction of Deferrable Views, particularly the new @defer block syntax, marks a significant leap in simplifying the dynamic loading of standalone components. @defer not only streamlines the process but also enhances Initial Load Performance by deferring heavy components, such as those with large third-party packages, until they are needed.

Leveraging the built-in on and custom when triggers along with the prefetch feature and the @placeholder, @loading and @error states, further empowers developers to optimize the user experience with a more responsive and efficient web application.

References

Performance Deep Dive Workshop

If you want to deep dive into Angular performance, we offer a dedicated Performance Workshop 🚀 - both in English and German.

This blog post was written by Alex Thalhammer. Follow me on GitHub, X or LinkedIn.