skip to content

In Angular 22, what does injectAsync() do, and why must the service it loads be auto-provided?

level: middleimportance: nice to knowfreq 20%

answer

  1. load the class, then resolve it
  2. returns a function returning a promise
  3. the injector captured up front
  4. no providers entry can name it
  5. onIdle for prefetching

basics

~20 s

injectAsync(() => import('./x')) returns a function that, when called, loads the service's chunk and resolves it from DI. The service must be providedIn: 'root' or @Service() because no providers array can reference a class that has not been loaded.

solid answer

~40 s

`injectAsync(loader, options?)`, new in Angular 22, is called in an injection context, captures the current `Injector`, and returns a function; calling that function runs the dynamic `import()` once, then resolves the loaded token from the captured injector, returning `Promise<T>`. Later calls reuse the same promise, so the chunk downloads once and the instance is the normal DI singleton. The service must be **auto-provided** — `@Injectable({providedIn: 'root'})` or `@Service()` — because the class carries its own registration; a `providers` array would need a static import of the class, which defeats lazy loading. Use it for heavy, rarely used services such as an export engine. `{prefetch: onIdle}` starts the download when the browser is idle, and a custom `PrefetchTrigger` can start it on hover.

code

ts · 25 lines
ts
import {Component, injectAsync, onIdle, signal} from '@angular/core';

@Component({
  selector: 'app-sales-report',
  template: `
    <button (click)="exportCsv()" [disabled]="busy()">Export</button>
  `,
})
export class SalesReport {
  readonly busy = signal(false);
  // ExportEngine is @Service() in ./export-engine; its chunk loads on demand
  private engine = injectAsync(() => import('./export-engine').then((m) => m.ExportEngine), {
    prefetch: onIdle,
  });

  async exportCsv() {
    this.busy.set(true);
    try {
      const engine = await this.engine();
      engine.download('sales.csv');
    } finally {
      this.busy.set(false);
    }
  }
}

go deeper

for a junior

Know that injectAsync loads a service's code only when you first need it, and that you await the function it returns.

for a middle

Explain the returned function, the captured injector, why the service must be providedIn root or @Service(), and how onIdle prefetching works.

for a senior

Use it where a heavy dependency affects bundle size, handle loading and failure states, and avoid static imports that pull the chunk back into the main bundle.

for a principal

Decide which services justify deferred loading, balancing interaction latency against initial bundle size, and set conventions for prefetch triggers across features.

## The problem it solves Some services pull in large libraries — a spreadsheet writer, a charting engine, a PDF renderer — but are used by few users. With a normal `inject(ExportEngine)`, the service and its library are part of the component's bundle and download on first render. Angular 22 adds `injectAsync()` so a component can depend on such a service **without** downloading it until it is needed. ## How it works ```ts private exporter = injectAsync(() => import('./export-engine').then((m) => m.ExportEngine)); ``` 1. `injectAsync` must run in an **injection context** (a field initializer, constructor or factory). It captures the current `Injector` immediately, because `inject()` cannot be called later from async code. 2. It returns a **function** — not the service and not a promise. Nothing is downloaded yet. 3. Calling `this.exporter()` runs the loader once: the dynamic `import()` fetches the separate chunk the bundler split out. 4. When the module arrives, Angular calls `injector.get(ExportEngine)` on the captured injector and resolves the returned promise with the instance. 5. Later calls reuse the same loading promise, so the chunk is fetched once and the instance is the usual singleton. If the class is the module's **default export**, you can pass the import directly — `injectAsync(() => import('./export-engine'))` — and Angular unwraps `default`. ## Why the service must be auto-provided The loaded class is resolved through ordinary DI, so some injector must know how to create it. There are two ways an injector can know: | Registration | Works with `injectAsync`? | Why | |---|---|---| | `@Injectable({providedIn: 'root'})` / `@Service()` | Yes | The class brings its own definition; root creates it on first request | | A `providers` array entry | No | The array needs a **static** import of the class, which pulls it into the eager bundle and defeats the point | | `@Service({autoProvided: false})` / plain `@Injectable()` | No | Nothing provides it, so resolution fails once it loads | The Angular docs state it directly: without auto-provisioning, Angular has no way to construct the service after it loads. ## Prefetching Waiting for a click before downloading adds latency. The `prefetch` option starts the download earlier: - `{prefetch: onIdle}` — `onIdle`, exported from `@angular/core`, resolves when the browser is idle (using `requestIdleCallback` where available). - `{prefetch: () => onIdle({timeout: 1000})}` — idle, but no later than roughly a second. - A custom **`PrefetchTrigger`** — any `() => Promise<void>` — for example resolving on `pointerenter` over the Export button. Prefetching is opportunistic: if the user clicks first, the load starts immediately and the `await` resolves as soon as the chunk is ready. ## Things to get right - **Handle the async boundary**: the method that uses the service becomes `async`; show a pending state if the download may be slow. - **Errors**: a failed network load rejects the promise. Because the loading promise is cached, later calls to the same function return that same rejection instead of downloading again, so surface the error (for example, suggest reloading) rather than offering an in-place retry. - **Do not also import the class statically** anywhere in eager code — a type-only import is fine, but a value import pulls the chunk back into the main bundle. - **Scope**: the instance is still an application singleton; `injectAsync` changes *when the code downloads*, not *how many instances exist*. ## When not to use it - The service is small or used on most visits — the extra round trip costs more than it saves. - The service is needed during the first render — use `inject()`; an awaited service cannot feed initial bindings synchronously. - The service must be scoped to a component or route — `injectAsync` only suits auto-provided classes. ## Relation to other lazy mechanisms Lazy routes and `@defer` blocks split **components** and their dependencies; `injectAsync` splits a **service** used by an already-loaded component. It complements, rather than replaces, route-level code splitting.

  • Why does injectAsync capture the Injector immediately instead of calling inject() when the chunk arrives?
    inject() only works synchronously inside an injection context, such as a field initializer. By the time the dynamic import resolves, that context is gone. So injectAsync reads the current Injector up front and later calls injector.get() on it with the loaded token.
  • Does injectAsync create a new service instance on every call?
    No. The loader promise is cached, so the chunk downloads once, and the service is resolved through normal DI, which returns the same root singleton each time. injectAsync changes when the code is downloaded, not how many instances exist.

saying these in an interview costs you the question

  • injectAsync returns the service instance directly
  • Any class listed in a providers array can be loaded with injectAsync
  • Each call to the returned function downloads the chunk again
  • injectAsync can be called from inside an async click handler
  • injectAsync creates a new instance on every call