Showcase requirements

Before we dive into the demonstration, it's worth laying out the specific goals we are targeting. These are the core requirements for the scenario we will build:

  • Initiate lazy loading once the target content scrolls into the viewport.
  • Properly manage both the loading phase and any potential error conditions.
  • Eliminate the flickering effect that can occur during content substitution.

These items cover the fundamental aspects of the proof-of-concept. Once those are in place, we can extend the example to cover more advanced scenarios, such as:

  • Kicking off the lazy load earlier by referencing a separate trigger element.
  • Deferring several distinct sections of the template at once.

Let's get started.

Template-Level Lazy Loading in Angular: The Classic Approach

Prior to Angular 17, developers could already build dynamic template sections through imperative APIs. These APIs enabled the creation of components, directives, pipes, and their associated styles at runtime, closely mimicking Angular's internal processes for component instantiation and view management. The dependencies destined for lazy loading didn't have to appear in the template markup, and the implementation relied on JavaScript's dynamic imports to fetch the relevant modules asynchronously during execution.

These earlier APIs offered flexibility in how lazy loading could be implemented, and the example below illustrates one of the most straightforward approaches.

The demonstration setup 🐱‍🏍

We'll start with a UserProfile component defined as follows:

@Component({
  ...
  template: `
    <div class="wrapper">
      <app-details></app-details>
    </div>

    <div class="wrapper wrapper-xl">
      <app-projects></app-projects>
      <app-achievements></app-achievements>
    </div>

    ...
  `,
  imports: [ProjectsComponent, AchievementsComponent]
  ...
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

The Details component provides an extensive user biography, while the Projects and Achievements components display relevant lists. Because the biography takes up considerable vertical space, these latter two components initially fall outside the visible area for users 👇:

The initial view of the profile page

Content positioned this way is commonly referred to as below-the-fold content. It represents an ideal scenario for lazy loading when the objective is to enhance the initial page render and minimize the overall application bundle size.

For starters, we'll lazy load only the Projects component and ensure it satisfies the core requirements outlined previously. Using the classic APIs, the initial template structure would look like this:

type DepsLoadingState = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETE' | 'FAILED';

@Component({
  ...
  template: `
    <div class="wrapper">
      <app-details></app-details>
    </div>

    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot /> // 👈 insert lazily loaded content here 

      <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'IN_PROGRESS'" [ngTemplateOutlet]="loadingTpl"></ng-template>
        <ng-template *ngIf="state == 'FAILED'" [ngTemplateOutlet]="errorTpl"></ng-template>
      </ng-container>

      <ng-template #loadingTpl>
        <app-projects-skeleton />
      </ng-template>

      <ng-template #errorTpl>
        <p>Oops, something went wrong!</p>
      </ng-template>
    </div>
    ...
  `,
  imports: [] // 👈 no need to import
  ...
})
export class UserProfileComponent {
  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');
}
Enter fullscreen mode Exit fullscreen mode

Observe that the components are absent from the template and excluded from the imports array in the component or NgModule metadata. Instead, a container or slot (referenced as #contentSlot) is established in the template via an ng-template element, where the dynamically loaded content will be placed.

Additionally, loading and error states are represented using separate ng-template elements. These templates reflect the current status of the dependency loading process, which is tracked through the depsState$ subject.

However, at this point, nothing actually occurs — there's no code initiating the loading sequence 😕.

To address the first requirement — commencing the load when the content enters the viewport — we need a trigger. This trigger represents the specific condition or action that must happen for the loading of the template dependencies to begin. Since the target content isn't initially part of the layout, we must define what will activate this process.

Introducing the placeholder template 💪

To provide some visible content in the layout, we'll add a temporary template. Once this temporary element scrolls into the viewport, it initiates the loading of the desired content. This interim template is called the placeholder 👇:

...
template: `
  ...
  <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'NOT_STARTED'" 
          [ngTemplateOutlet]="placeholderTpl">
        </ng-template>
        ...
  </ng-container>

  <ng-template #placeholderTpl>
    <p>Projects List will be rendered here...</p> // 👈 trigger element
  </ng-template>
  ...
`
...
Enter fullscreen mode Exit fullscreen mode

This placeholder, also referred to as the trigger element, is declared as an ng-template. It's designed to be removed once it has successfully triggered the loading of the template dependencies — in this case, the Project component.

With the trigger element in position, the remaining task is to define the actual trigger logic that starts the loading when the element becomes visible.

For this, the IntersectionObserver Web API comes into play. The logic is packaged inside a directive. This directive emits an event when the element it's attached to (the trigger element) becomes visible in the viewport and subsequently stops observing that element, as shown below 👇:

@Directive({
    selector: '[inViewport]',
    standalone: true
})
export class InViewportDirective implements AfterViewInit, OnDestroy {
    private elRef = inject(ElementRef);

    @Output()
    inViewport: EventEmitter<void> = new EventEmitter();

    private observer!: IntersectionObserver;

    ngAfterViewInit() {
        this.observer = new IntersectionObserver((entries) => {
            const entry = entries[entries.length - 1];
            if (entry.isIntersecting) {
                this.inViewport.emit();
                this.observer.disconnect();
            }
        });

        this.observer.observe(this.elRef.nativeElement)
    }

    ngOnDestroy(): void {
        this.observer.disconnect();
    }
}
Enter fullscreen mode Exit fullscreen mode

Upon receiving the emitted event, the UserProfile component takes charge of the load sequence:

@Component({
  ...
  template: `
    ...
    <div class="wrapper wrapper-xl">
      ...
      <ng-template #placeholderTpl>
        // 👇 apply directive to the trigger element
        <p (inViewport)="onViewport()">
            Projects List will be rendered here...
        </p> 
      </ng-template>
      ...
    </div>
    ...
  `,
  imports: [InViewportDirective]
  ...
})
export class UserProfileComponent {
  @ViewChild('contentSlot', { read: ViewContainerRef }) 
  contentSlot!: ViewContainerRef;

  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');

  onViewport() {
    this.depsState$.next('IN_PROGRESS');

    const loadingDep = import("./user/projects/projects.component");
    loadingDep.then(
      c => {
        this.contentSlot.createComponent(c.ProjectsComponent);
        this.depsState$.next('COMPLETE');
      },
      err => this.depsState$.next('FAILED')
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

To load the component asynchronously, JavaScript's dynamic import is used. After the import resolves, the tracking state is updated to reflect the progress of the loading task, ensuring the template stays in sync. Since the loading orchestration resides in the component class, the template's container/slot must be queried. The host of the loaded component is then created and inserted into that container, as depicted here 👇:

Lazy Loading of projects with flickering issue
This setup yields a working result. However, a closer look reveals that the Project component loads so rapidly that the placeholder is barely noticeable, and the loading template never appears since the content renders almost instantly. This can lead to a brief flicker during the load phase.

This brings us to the third core principle. The issue can be addressed by carefully coordinating the visibility of the placeholder and loading templates, as implemented below:

function delay(timing: number) {
  return new Promise<void>(res => {
    setTimeout(() => {
      res()
    }, timing);
  })
}

@Component({...})
export class UserProfileComponent {
  @ViewChild('contentSlot', { read: ViewContainerRef }) 
  contentSlot!: ViewContainerRef;

  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');

  onViewport() {
    // time after the loading template will be rendered
    delay(1000).then(() => this.depsState$.next('IN_PROGRESS'));

    const loadingDep = import("./user/projects/projects.component");
    loadingDep.then(
      c => {
        // minimum time to keep the loading template rendered
        delay(3000).then(() => {
          this.contentSlot.createComponent(c.ProjectsComponent);

          this.depsState$.next('COMPLETE')
        });
      },
      err => this.depsState$.next('FAILED')
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

This strategy provides users with a clearer visual cue about the ongoing process and results in a more fluid experience 🤗:

Lazy Loading of projects without flickering issue
All three key objectives have been met. A more complex but common requirement arises when we want to start loading even earlier, just before the placeholder itself enters the viewport. This scenario calls for separating the trigger element from the placement template. Another element positioned higher up in the template serves as the trigger, as shown below:

@Component({
  ...
  template: `
     ...
    // 👇 trigger element somewhere above in the template
    <span (inViewport)="onViewport()"></span>

    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot /> // 

      <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'NOT_STARTED'" [ngTemplateOutlet]="placeholderTpl"></ng-template>
        <ng-template *ngIf="state == 'IN_PROGRESS'" [ngTemplateOutlet]="loadingTpl"></ng-template>
        <ng-template *ngIf="state == 'FAILED'" [ngTemplateOutlet]="errorTpl"></ng-template>
      </ng-container>

      <ng-template #placeholderTpl>
        <p>Projects List will be rendered here...</p>
      </ng-template>
      ...
    </div>
    ...
  `,
  imports: [InViewportDirective]
  ...
})
export class UserProfileComponent {
  ...
  onViewport() {
    // same implementation as above
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, as the user scrolls and the trigger element (a span) comes into view, the dependency loading kicks off. The animation below demonstrates this behavior:

Early trigger of lazy loading projects
This is clearly evident, as the loading message appears in the view as you scroll toward the Projects section. It works well 😎.

Lazy Loading multiple components

The current logic is concise and imperative. However, there's one final requirement to address: loading more than one component. In this example, the Achievements component is also prepared to load alongside Projects.

For loading multiple components, we have two options: load them individually or load them together. Opting for the first approach means duplicating the work done for the Projects component for each additional component. Although the process is straightforward, it accumulates a significant amount of boilerplate 😁.

Choosing the latter approach requires minimal adjustments. It involves updating the loading and placement templates to account for both components and modifying the class logic to manage the loading of two dependencies. Notice the use of Promise.allSettled to manage the simultaneous dynamic imports seamlessly:

function loadDeps() {
  return Promise.allSettled(
    [
      import("./user/projects/projects.component"),
      import("./user/achievements/achievements.component")
    ]
  );
}

@Component({
  template: `
    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot />

      <ng-template #placeholderTpl>
        <p (inViewport)="onViewport()">
            Projects and Achievements will be rendered here...
        </p>
      </ng-template>

      <ng-template #loadingTpl>
        <h2>Projects</h2>
        <app-projects-skeleton />

        <h2>Achievements</h2>
        <app-achievements-skeleton />
      </ng-template>
      ...
    </div>
  `,
})
export class UserProfileComponent {
  ...
  async onViewport() {
    await delay(1000);
    this.depsState$.next('IN_PROGRESS');

    const [projectsLoadModule, achievementsLoadModule] = await loadDeps();
    if (projectsLoadModule.status == "rejected" || achievementsLoadModule.status == "rejected") {
      this.depsState$.next('FAILED');
      return;
    }

    await delay(3000);

    this.contentSlot.createComponent(projectsLoadModule.value.ProjectsComponent);
    this.contentSlot.createComponent(achievementsLoadModule.value.AchievementsComponent);

    this.depsState$.next('COMPLETE');
  }
}
Enter fullscreen mode Exit fullscreen mode

Even with multiple components, the same template container/slot is used for insertion. The result is shown here 👇:

Lazy loading of Projects and Achievements
Error handling for failed dependencies will vary by project, and you're encouraged to implement a strategy that fits your specific needs.

That concludes the classic approach — quite a bit of manual work, isn't it? Now, let's consider the modern alternative.

Modern Template-Level Lazy Loading: Deferrable Views

Now we turn to the modern API and apply it to the same UserProfile component we have been using, so the comparison with the classic techniques stays direct. As mentioned in the opening section, Angular 17 shipped Deferrable Views, which shift the heavy lifting from developer-written logic to the framework itself — more precisely, to the compiler.

With the core concepts already laid out, the same outcome now requires only the template snippet shown here 👇:

@Component({
  ...
  imports: [... ProjectsComponent],
  template: `
    <div class="wrapper">
       <app-details />
    </div>

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
         <app-projects />
       } @placeholder {
         <p>Projects will be rendered here...</p>
       } @loading {
          <app-projects-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

The template-driven nature is immediately visible: no manual state tracking, no async handling, and the component class stays free of such logic. The content to be deferred is wrapped in the @defer block, and the trigger condition follows as a parameter (in this case, on viewport). In addition, the placeholder, loading, and error states are each expressed as named template blocks — no ng-templates required — with the placeholder block serving as the trigger element:

Deferrable loading of projects when in the viewport
One detail worth noting: because the Project component appears directly in the template, it has to be listed in the component's metadata under imports. This is only for the compiler to recognize and resolve the template dependency — the compiler handles the rest transparently.

There is, however, a subtle flicker that can happen for the same reasons discussed earlier. To avoid it, you must coordinate when the placeholder or loading state is displayed. The @loading block offers two optional parameters, minimum and after, to fine-tune this timing:

@Component({
  ...
  template: `
    ...
    <div class="wrapper wrapper-xl">
       @loading (after 1s; minimum 3s) {
          <app-projects-skeleton />
       }
    </div>
    ...
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

These parameters dictate when the loading block appears and how long it must stay up. In this example, they ensure the loading state is not shown until one second after the process starts, and once visible, it remains for at least three seconds:

Deferrable loading of projects with no flickering

The deferrable views work only with standalone dependencies.

Triggers in deferrable views can also receive their own parameters, just like the template blocks. The viewport trigger, for instance, accepts an optional DOM element that becomes the trigger — replacing the placeholder template. This lets you start loading dependencies before the user actually reaches the section where the content will render:

@Component({
  ...
  imports: [ProjectsComponent],
  template: `
    ...
    <span #triggerEl></span>
    ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport(triggerEl)) {
         <app-projects />
       } 
       ...
    </div>
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

With this setup, the loading process kicks in when the user scrolls and the trigger element (the span) enters the viewport. See it in action below 👇:

Early trigger of lazy loading of projects
As you scroll toward the Projects section, the loading template appears, confirming the behavior is working as expected 😎.

Lazy Loading Multiple Components

There is one remaining goal to cover: loading more than one component lazily. For this demonstration, the Achievements component is ready to be deferred alongside Projects.

Following the same logic as before, you have two choices: load both together or load each one independently. With the new @defer block, both options are straightforward 😎.

Loading Them Together 🚀

To load both components at once, the Achievements component must be added to the imports array in the component metadata and then placed inside the same @defer block as Projects:

@Component({
  ...
  imports: [... ProjectsComponent, AchievementsComponent],
  template: `
     ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
         <app-projects />
         <app-achievements />
       } @placeholder () {
         <p>Projects and Achievements will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Projects</h2>
          <app-projects-skeleton />

          <h2>Achievements</h2>
          <app-achievements-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

The loading and placeholder blocks are then updated to reflect the combined loading of both components 👇:

Deferrable loading of projects and achievements

Loading Them Separately 🚀

For independent loading, each component is placed in its own @defer block, with respective placeholder, loading, and error blocks defined:

@Component({
  ...
  imports: [... ProjectsComponent, AchievementsComponent],
  template: `
     ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
          <app-projects  />
       } @placeholder () {
          <p>Projects will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Projects</h2>
          <app-projects-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }

       @defer (on viewport) {
          <app-achievements />
       } @placeholder () {
          <p>Achievements will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Achievements</h2>
          <app-achievements-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}
Enter fullscreen mode Exit fullscreen mode

Compared to the classic approach, the code volume is drastically reduced. Adding as many @defer blocks as needed takes minimal effort, and you can trust it will just work 🤗:

Deferrable loading of projects and achievements (separately)
That wraps up the modern approach. The features above only scratch the surface of what Deferrable Views can do. For a thorough walkthrough, refer to these resources:

Conclusion

Lazy loading serves as a performance optimization across web frameworks, and Angular is no exception. Route-level lazy loading has gained wide adoption in the Angular ecosystem, yet template-level lazy loading remains underused despite the fact that the existing APIs have long proven their reliability. The required effort, the split between class and template, and the imperative code involved make these older solutions far from developer-friendly.

With Angular 17's @block template syntax, deferrable views deliver a modern, declarative, and template-driven API for postponing the rendering of template sections until a later time. The only remaining task is to choose the combination of these features that fits your specific scenario.

The complete source code is available for you to explore and experiment with here: https://github.com/ilirbeqirii/lazy-load-component

Special thanks to @kreuzerk and @eneajaho for review.

Thanks for reading!

I hope you enjoyed the read 🙌. If you liked the article, feel free to pass it along to friends and colleagues.
For any questions or feedback, drop a comment below 👇.
To stay updated on future articles, follow me on @lilbeqiri, dev.to, or Medium. 📖