Home / Articles

Code-Splitting Heavy Angular Services on Demand with injectAsync

Why a statically injected service drags its libraries into the main Angular bundle, and how injectAsync lazy-loads services on user intent, including conditional loading.

Publisher: REACTAPP.TOP6 min read1484 words

This article is published in English.

Sources: Stop Loading Massive Services: Mastering injectAsync in Angular 22

Route-level lazy loading and @defer blocks keep rarely used pages and components out of an Angular app's initial bundle, but services are a different story. A single inject() of a heavy service in an always-visible component pulls that service, and every library it imports, into the main chunk. This article explains why that happens, why older workarounds were awkward, and how the injectAsync API introduced in Angular 22 lets you load a service's code only when the user actually needs it.

Treat the API details below as a snapshot: at the time of writing injectAsync is new, so confirm its exact signature, stability status and usage rules in the current Angular documentation before adopting it.

A familiar bundle problem

Picture an enterprise dashboard with healthy runtime performance but a poor first load, where the initial JavaScript payload has grown to 2.4 MB. Running the build through source-map-explorer shows that the largest offender is not a UI component at all. It is a PdfExportService that relies on heavy client-side rendering libraries to produce detailed multi-page financial reports.

The twist is that only around 5% of users ever press "Export to PDF". Because the service is injected into the dashboard's header component, however, every user downloads roughly 600 KB of export code on every first visit. The cost is paid by the 95% who never use the feature.

Angular already offers @defer and route-based code splitting for components and pages. What it did not offer until now was a clean way to lazy-load a service while keeping it inside the dependency injection (DI) system.

Why a static inject() lands in the main bundle

To see what injectAsync changes, it helps to know how bundlers such as esbuild and Vite interact with Angular DI. Consider a header component that injects the report service in the usual way:

import { Component, inject } from '@angular/core';
import { HeavyReportService } from './heavy-report.service';

@Component({ ... })
export class DashboardHeaderComponent {
  // The Static Analysis Trap
  private reportService = inject(HeavyReportService);

  exportData() {
    this.reportService.generate();
  }
}

The top-level import statement is what matters. The bundler analyses imports statically, sees that DashboardHeaderComponent depends on HeavyReportService, and places that service, together with every third-party library it imports, in the same chunk as the component. If the component is part of the initial view, so is the PDF library. The inject() call itself is not the problem; the static import that makes it possible is.

The older workaround and its friction

Experienced developers have long avoided this by moving to a dynamic await import(...) inside the click handler, so the bundler emits the service as a separate chunk.

The difficulty is that a dynamically imported class is just a class. If you construct it yourself, it sits outside Angular's injector, so dependencies such as HttpClient for fetching a template, or an auth service that attaches a token, are not supplied. Getting a properly wired instance meant also injecting an Injector up front and resolving the loaded class through it, which works but spreads DI plumbing through component code and is easy to get wrong. (For a refresher on how resolution itself works, see how Angular dependency injection resolves services with inject.)

What injectAsync changes

injectAsync closes the gap between an ECMAScript dynamic import and Angular's injector tree. You describe the dependency with a loader function, and in effect you tell the bundler: keep this out of the main chunk, and fetch and instantiate it only when asked.

Dependency injection no longer has to be synchronous. Loading a service's code can wait until the moment a user shows intent.

Step 1: write the heavy service as usual

Nothing about the service itself needs to change. It can still use inject() and anything else from the Angular ecosystem. Here it injects HttpClient and imports pdfMake, the large dependency you want out of the initial load.

// heavy-report.service.ts (This will become its own separate JS chunk)
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import * as pdfMake from 'pdfmake/build/pdfmake'; // Massive dependency

@Injectable({ providedIn: 'root' })
export class HeavyReportService {
  private http = inject(HttpClient);

  async generate(data: any) {
    // Complex, heavy PDF generation logic
  }
}

Because nothing in the eagerly loaded code imports this file statically, the bundler can emit it, and pdfMake, as a separate chunk.

Step 2: resolve the service at the interaction boundary

Instead of injecting the service as a class field, the component requests it when the export button is clicked. A signal, isExporting, drives the button's disabled state and label while the chunk downloads and the work runs.

// The Modern Code-Split Architecture
import { Component, injectAsync, signal } from '@angular/core';

@Component({
  selector: 'app-dashboard-header',
  template: `
    <button (click)="onExport()" [disabled]="isExporting()">
      {{ isExporting() ? 'Loading Exporter...' : 'Export PDF' }}
    </button>
  `
})

export class DashboardHeaderComponent {
  isExporting = signal(false);

  async onExport() {
    this.isExporting.set(true);
    try {

      // 1. The Magic: The browser downloads the chunk over the network right now.
      // Angular instantiates it and wires up all its DI dependencies (like HttpClient).
      const reportService = await injectAsync(
        () => import('./heavy-report.service').then(m => m.HeavyReportService)
      );

      // 2. Execute the heavy logic
      await reportService.generate({ id: 123 });

    } catch (error) {
      console.error('Failed to load or execute the report service', error);
    } finally {
      this.isExporting.set(false);
    }
  }
}

When the handler runs, the browser fetches the service's chunk, Angular creates the instance and supplies its dependencies such as HttpClient, and the component calls generate(). The try/catch/finally matters here: a network failure while loading the chunk is now a possible runtime error, and the finally block guarantees the button is re-enabled either way.

One detail to verify against the documentation: like inject(), DI functions normally have to be called within an injection context, such as a constructor or field initializer, not inside an arbitrary event handler. If your Angular version enforces that for injectAsync, call it in a field initializer to obtain a loader and invoke that loader from the click handler instead.

After a production build, the output contains a small main.js and a separate heavy-report.service-[hash].js chunk. Users who never click export never download the PDF library at all.

Conditional loading: ship only the code a user may run

Code-splitting a big library is the obvious use, but the same mechanism enables conditional DI. Suppose free accounts use a StandardDataProcessor while premium corporate accounts get an AIProDataProcessor.

The traditional approach bundles both implementations and uses a factory provider to pick one at runtime, so every user downloads both. With injectAsync, the branch decides which chunk is fetched:

async processData(payload: any) {
  this.isProcessing.set(true);

  let processor;

  if (this.currentUser.tier === 'PRO') {
    // Only Pro users pay the network cost for the AI chunk
    processor = await injectAsync(
      () => import('./ai-pro-processor.service').then(m => m.AiProProcessorService)
    );
  } else {
    // Free users download the lightweight chunk
    processor = await injectAsync(
      () => import('./standard-processor.service').then(m => m.StandardProcessorService)
    );
  }

  await processor.execute(payload);
  this.isProcessing.set(false);
}

Pro users pay the network cost of the AI processor, and everyone else gets the lightweight one. This is runtime polymorphism where the choice also determines what is downloaded, decoupling the size of what a user loads from the total size of the codebase. As written, isProcessing stays true if loading or execute() throws; wrap the body in try/finally as in the export example. Giving both processors a shared interface also keeps processor properly typed instead of implicitly any.

When not to reach for it

Lazy loading trades initial bundle size for a delay at the moment of use. For a service most users need on every visit, deferring it just moves the wait to a worse moment. It pays off for heavy, rarely used or tier-specific functionality. You can also soften the delay by triggering the load on hover or focus of the button, before the click.

Key takeaways

  • Audit first: use source-map-explorer or a similar tool to find heavy services that are used only rarely or conditionally.
  • Remove static injections of those services from eagerly loaded components, since the static import is what drags them into the main bundle.
  • Load them at the point of user intent with injectAsync and a dynamic import(), keeping full DI support.
  • Show loading state and handle errors, because each deferred service now involves a network request for its chunk.
  • Use the same pattern to download only the implementation a given user is allowed to run.