injectAsync() (new in v22) does for services what @defer does for components: the service’s code — and that of the libraries it imports — ends up in a separate chunk, downloaded only when you really need it. Perfect for heavy features used rarely: PDF/Excel export, encryption, file parsing, large SDKs.
How it works
import { Component, injectAsync } from '@angular/core';
@Component({
selector: 'app-report-toolbar',
template: `<button (click)="exportPdf()">Export PDF</button>`,
})
export class ReportToolbar {
// It loads NOTHING now: it returns a function () => Promise<PdfExporter>
private readonly pdf = injectAsync(() => import('./pdf-exporter').then((m) => m.PdfExporter));
async exportPdf() {
const exporter = await this.pdf(); // the first time: downloads the chunk + creates the service
exporter.download(this.data()); // the next calls: instant, the same instance
}
}Prefetch
import { injectAsync, onIdle } from '@angular/core';
// Prefetch when the browser is idle (requestIdleCallback)
readonly pdf = injectAsync(() => import('./pdf-exporter'), { prefetch: onIdle });
// With a timeout: idle, but at the latest after 2 s
readonly charts = injectAsync(() => import('./charts'), { prefetch: () => onIdle({ timeout: 2000 }) });
// Your own trigger: any function that returns a Promise
readonly editor = injectAsync(() => import('./rich-editor'), {
prefetch: () => new Promise((resolve) => this.hovered.subscribe(() => resolve())),
});@defer vs injectAsync vs lazy routes
@defer injectAsync loadComponent (route)
─────────────────────────────────────────────────────────────────────────────────────────
What is lazy components in template services (logic) a whole page
When it loads trigger in the template at the first await on navigation
Prefetch prefetch on ... { prefetch: onIdle } PreloadingStrategy
Example chart, map, editor PDF, Excel, encryption, /admin, /reports
parser, large SDKsLoading state and errors
The first call downloads a chunk, so it can take time and it can fail. Treat it like a request: a signal for “loading”, a try/catch for the error and a finally that stops the indicator.
@Component({
selector: 'app-export-button',
template: `
<button (click)="exportPdf()" [disabled]="loading()">
{{ loading() ? 'Preparing…' : 'Export PDF' }}
</button>
@if (failed()) {
<p role="alert">We could not load the export. Reload the page and try again.</p>
}
`,
})
export class ExportButton {
private readonly pdf = injectAsync(() => import('./pdf-exporter').then((m) => m.PdfExporter));
protected readonly loading = signal(false);
protected readonly failed = signal(false);
protected async exportPdf() {
this.loading.set(true);
this.failed.set(false);
try {
const exporter = await this.pdf(); // the first time it downloads the chunk; after that it is instant
await exporter.export(this.report());
} catch {
this.failed.set(true);
} finally {
this.loading.set(false);
}
}
}What actually happens
injectAsync() is small: it keeps the promise returned by import(), and when it resolves it asks for the class from the injector where you called the function. That is why the lazy service can itself use inject() (for example HttpClient), and a provider on the component or in tests takes precedence, exactly as with inject().
An important detail: the promise is kept even when it fails. If the download fails once (offline, a new deploy that removed the old chunk), every later call fails the same way until the page is reloaded. The same happens if the prefetch fails: the error is silently ignored, but the first real call receives it. So the message for the user should suggest reloading the page, not “try again”.
Testing
Because the result comes from the injector, in tests you can provide a fake for the service class. The component gets the fake, and the heavy library is not used at all.
class FakePdfExporter {
readonly exported: unknown[] = [];
async export(report: unknown) {
this.exported.push(report);
}
}
it('exports without downloading the real library', async () => {
TestBed.configureTestingModule({
// injectAsync asks the injector for the class, so it receives the fake
providers: [{ provide: PdfExporter, useClass: FakePdfExporter }],
});
const fixture = TestBed.createComponent(ExportButton);
fixture.nativeElement.querySelector('button').click();
const fake = TestBed.inject(PdfExporter) as unknown as FakePdfExporter;
await vi.waitFor(() => expect(fake.exported.length).toBe(1));
});The same idea for components is @defer — lazy loading in templates; how to check what you saved in the bundle is covered in Performance — a checklist.
When YES, when NO
- The service imports a large library (jspdf, xlsx, pdf.js, highlight, crypto).
- The feature is used by few users or rarely (export, import, admin).
- You want a small initial bundle without moving the code into a separate route.
- Small services used often → a normal
inject(). - Services the first render depends on (auth, config) → they would delay the UI.
- The service is already used eagerly somewhere else → it no longer splits into a chunk.
Common pitfalls
- The service must be auto-provided (
@Service()/providedIn: 'root') — otherwise there is nowhere to create it from. - A static import somewhere else: if another file does
import { PdfExporter }(not justimport type), the code ends up in the main bundle and lazy loading has no effect. - Handle network errors:
await this.pdf()can fail (offline). Wrap it in atry/catchand show a message. - Injection context:
injectAsync()is called as a field (likeinject()); the returned function can be called anywhere.
Cheat sheet
// lazy service, with a named export
private readonly pdf = injectAsync(() => import('./pdf-exporter').then((m) => m.PdfExporter));
// with a default export
private readonly pdf = injectAsync(() => import('./pdf-exporter'));
// downloaded ahead of time, when the browser is idle
private readonly pdf = injectAsync(() => import('./pdf-exporter'), { prefetch: onIdle });
const exporter = await this.pdf(); // always awaited; handle the error
import type { PdfExporter } from './pdf-exporter'; // only import type elsewhere, otherwise the chunk is no longer split