Angular 22 makes injectAsync stable, allowing an auto-provided service and its dependency graph to move into a separate JavaScript chunk. The API is only one line; the engineering work is choosing a genuinely optional service, keeping every eager import out of its graph, designing the asynchronous first use, and proving the heavy code is absent from the initial route. There is also an important failure rule: Angular caches the loader promise. If the chunk request rejects, another call from the same injectAsync instance reuses that rejected promise rather than starting a fresh download.

Choose a service worth splitting

injectAsync is useful when a service owns a large dependency and only a minority of sessions use the feature: spreadsheet or PDF export, a rich editor, a charting engine, image processing, an administration console, or an advanced import workflow. It is usually noise for a small service whose dependencies are already required by the initial route.

Take a production build before changing code. Record initial JavaScript bytes, the heavy package’s contribution, route startup timing, feature usage rate, and the acceptable delay on first activation. The expected saving is approximately the code that leaves the initial graph; the expected cost is another request plus parse, evaluation, and service construction when the feature first runs.

Do not combine the split with an unrelated behavioral migration. If the application is also adopting Angular 22 zoneless Reactive Forms, measure and release the changes separately so a stale view and a failed lazy chunk cannot be confused during diagnosis.

Build an actual lazy service boundary

The service must be auto-provided with @Service() or @Injectable({providedIn: 'root'}). Angular captures the current injector when injectAsync runs, loads the provider token asynchronously, and then resolves that token through normal dependency injection. The resolved service still behaves like the corresponding singleton.

// report-exporter.service.ts
import {Service} from '@angular/core';
import * as XLSX from 'xlsx';

@Service()
export default class ReportExporter {
  async export(rows: readonly Record<string, unknown>[]): Promise<void> {
    const worksheet = XLSX.utils.json_to_sheet([...rows]);
    const workbook = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(workbook, worksheet, 'Report');
    XLSX.writeFile(workbook, 'report.xlsx');
  }
}

Making the service the default export lets the loader pass the dynamic import directly; Angular unwraps the default provider token. A named export also works with .then(module => module.ReportExporter). What matters is that the heavy library is reachable through the dynamic module graph and not through any eager path.

import {Component, injectAsync, signal} from '@angular/core';

@Component({
  selector: 'app-report-actions',
  template: `
    <button type="button" [disabled]="loading()" (click)="export()">
      {{ loading() ? 'Preparing…' : 'Export report' }}
    </button>

    @if (loadFailed()) {
      <p role="alert">
        The exporter could not be loaded. Save your work, then reload this page.
      </p>
    }
  `,
})
export class ReportActions {
  protected readonly loading = signal(false);
  protected readonly loadFailed = signal(false);

  private readonly getExporter = injectAsync(
    () => import('./report-exporter.service'));

  protected async export(): Promise<void> {
    if (this.loading() || this.loadFailed()) return;

    this.loading.set(true);
    try {
      const exporter = await this.getExporter();
      await exporter.export(this.currentRows());
    } catch (error) {
      this.loadFailed.set(true);
      this.recordChunkFailure(error);
    } finally {
      this.loading.set(false);
    }
  }

  private currentRows(): readonly Record<string, unknown>[] {
    return [];
  }

  private recordChunkFailure(error: unknown): void {
    // Send a sanitized error class and release ID, never report data.
  }
}

The call site makes the first-use delay visible and prevents duplicate clicks. It also acquires the service before reading or transforming sensitive report data, so a failed chunk does not leave a half-created export. In a real screen, preserve the user’s filters and edits before offering a reload.

Remove imports that pull the chunk back

A dynamic import creates a possible split; the complete import graph decides whether it survives. One static import of the exporter or its heavy dependency from eager code can put the same code back into the initial bundle.

  • Do not re-export the lazy service from a barrel imported by application startup.
  • Do not list the service in an eager provider array; it must be auto-provided.
  • Use import type when only a TypeScript type crosses the boundary.
  • Keep the heavy package import inside the lazy service’s graph.
  • Search route configuration, effects, initializers, tests, and shared utilities for eager references.
  • Avoid a shared helper that imports both common code and the heavy library; split that helper first.

The service may inject ordinary dependencies, but those dependencies are lazy only if no eager code needs them. A singleton being constructed late does not guarantee its module was downloaded late. Only build output can prove module placement.

Choose demand loading or prefetching

By default, Angular calls the loader on the first invocation of the returned function. That maximizes startup savings but puts all network and parse latency on the first user action. The prefetch option starts loading when a trigger promise resolves. Angular’s onIdle trigger uses the configured idle service, which uses requestIdleCallback when available and falls back to a timer.

import {injectAsync, onIdle} from '@angular/core';

private readonly getExporter = injectAsync(
  () => import('./report-exporter.service'),
  {
    prefetch: () => onIdle({timeout: 1_000}),
  },
);

Prefetch is opportunistic. If the user invokes the feature first, loading starts immediately and both paths share the same promise. The timeout ensures prefetch eventually begins on a busy page, but it can also make every session download a feature that few users open. Demand-load rare features, idle-prefetch common features after critical startup, and use a custom intent trigger only when it predicts real use without adding event-listener leaks.

Design for a permanently rejected loader promise

The Angular 22 implementation stores the first loader promise in a closure. Successful calls reuse it, which prevents duplicate downloads. A rejected promise is stored too. Calling the returned function again from the same component instance returns that rejection again; it does not execute import() a second time. A simple Retry button around await this.getExporter() therefore does not repair a chunk-load failure.

Failures occur when the device is offline, a proxy or service worker serves incompatible files, a content-security policy blocks the chunk, or a long-lived tab references a hashed chunk removed by a new deployment. Preserve unsaved work, record a sanitized error class and application release ID, and offer a deliberate reload or navigation that recreates the injection context. Do not automatically reload a form and discard the user’s work.

Prefetch does not change the recovery rule. Angular suppresses the prefetch chain’s rejection to avoid an unhandled error, but the cached loader promise remains rejected. The later feature call still fails, so monitor first-use failures even when the prefetch itself appears quiet.

Prove the split in a production build

Development mode and source layout are not evidence. Build the same configuration deployed to production, save the stats file, and compare it with the baseline. Project names and output paths vary, so set the actual distribution directory in CI rather than copying a placeholder.

npx ng build --configuration production --stats-json

DIST_DIR="dist/my-app/browser"
find "$DIST_DIR" -maxdepth 1 -type f -name '*.js' \
  -printf '%f %s bytes\n' | sort

# Search built JavaScript only as a diagnostic, not as the sole proof.
rg -l "ReportExporter|xlsx" "$DIST_DIR" --glob '*.js'

The acceptance condition has two parts. The heavy library must be absent from every initial route chunk, and it must appear in a non-initial chunk reachable from the exporter boundary. Generated file names are unstable, so avoid CI assertions against one hash. Prefer the CLI stats graph and an initial-bundle size budget, with a diagnostic symbol search to explain failures.

Compare compressed transfer size as well as raw bytes, then inspect parse and evaluation time on a representative mobile device. Moving 300 KB out of startup is useful only if the application does not immediately prefetch it and if the first-use delay remains acceptable.

Verify first use and failure in the browser

Use a clean browser context and a production build. Before clicking Export, verify that the identified lazy chunk is absent from network requests. Click once and verify one chunk request, the loading state, a completed export, and no console error. Click again and verify no second chunk download. Repeat under slow network and CPU throttling.

  1. Abort the lazy JavaScript request and assert that the alert appears.
  2. Click again without recreating the component and assert that no fresh chunk request occurs; this proves the cached rejection behavior.
  3. Preserve the form state, reload the application, restore network access, and verify that the feature can load.
  4. Serve old HTML against a deployment that removed its referenced chunk and verify the stale-client message.
  5. Verify Content Security Policy and service-worker caching in the same topology used in production.

This test is more valuable than a unit assertion that injectAsync returns a promise. It proves the user-visible wait, the built artifact, deployment compatibility, and the one failure mode that a local dev server rarely exposes.

Measure the trade-off after deployment

Roll out to a small traffic slice and compare initial JavaScript bytes, largest-contentful paint on the affected route, parse and evaluation time, feature usage rate, first-activation latency, lazy-chunk error rate, reload recovery, and repeat activation latency. Segment chunk failures by application release and browser version; do not attach report contents or user-entered data.

A smaller initial bundle is not automatically a better experience. If 80 percent of sessions export immediately, demand loading merely moves work onto a critical click. If 2 percent export and startup improves measurably, the split is likely valuable. Let usage and timing data choose demand, prefetch, or an eager rollback.

Migration and rollback checklist

  • Select a large, optional service from production-build evidence.
  • Auto-provide it with @Service() or root @Injectable.
  • Move the heavy dependency behind the dynamic import and remove every eager path.
  • Make loading, disabled, failure, and reload states explicit in the UI.
  • Choose demand or prefetch from usage frequency and first-use latency.
  • Prove the dependency is absent from initial chunks in a production build.
  • Prove one download, promise reuse, failure behavior, and reload recovery in the browser.
  • Canary the change and keep an eager implementation or feature switch available for rollback.

injectAsync provides a clean architectural seam, not a performance result by itself. The result is real only when the production graph excludes the heavy code from startup and the delayed feature remains understandable when the network—or the deployment—does not cooperate.

References

Write A Comment

Exit mobile version