Angular 22 quietly shipped one of the most developer-friendly DI improvements in years — and most people are still sleeping on it.
Have you ever injected a heavyweight service at the top of a component, knowing full well that 90% of your users will never actually trigger the feature that needs it? You import it, Angular boots it eagerly, it pulls in its own dependencies, and suddenly a chunk of your bundle is loaded just in case someone clicks "Export PDF." Sound familiar?
That exact pain point is what injectAsync() — one of the standout additions in Angular 22 — is designed to fix. And once you see how it works, you will start questioning every inject() call in your codebase.
In this article, you will learn:
- What
injectAsync()is and why it was introduced - How it differs from the familiar
inject()function - Real-world use cases where lazy injection makes a meaningful difference
- How to write unit tests for components that use it
- Bonus tips to squeeze even more out of Angular 22's DI improvements
If this is the kind of deep-dive content you find useful, consider following me here on Medium — I publish one practical Angular article every week, and the Angular 22 series is just getting started.
Before we dive into the examples, a quick note: The code snippets provided here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular. Always refer to the official Angular documentation for the most current API and syntax.
What Changed in Angular 22's Dependency Injection
Angular 22 landed on June 3, 2026, and it came with a headline list that includes Signal Forms going stable, Resources graduating to production-ready, and OnPush becoming the default change detection strategy. Those are big deals. But tucked in alongside them is injectAsync() — a quieter addition that solves a very specific and very common problem.
To understand why it matters, you need to understand what eager injection actually costs you.
When you write inject(HeavyAnalyticsService) in a component, Angular resolves that dependency the moment the component is created. The service gets instantiated, its own dependencies get resolved, and everything that service depends on gets pulled into the initial load. If that service is only needed when a user clicks a specific button — maybe one that exports a report, or opens an advanced settings panel — you have just loaded that entire dependency graph on behalf of every user who lands on the page, even those who never click that button.
injectAsync() breaks that pattern. It lets you declare a dependency but defer its actual resolution until the moment you need it.
The Old Way: inject() and Its Limits
Here is what the traditional pattern looks like. You have a component with a button that triggers a PDF export. The PdfReportService is non-trivial — it pulls in a PDF generation library and potentially some charting utilities.
// pdf-report.service.ts
import { Service } from '@angular/core';
@Service()
export class PdfReportService {
generate(data: ReportData): Promise<Blob> {
// imagine this pulls in a large PDF library
return heavyPdfLibrary.render(data);
}
}
// dashboard.component.ts
import { Component, inject } from '@angular/core';
import { PdfReportService } from './pdf-report.service';
@Component({
selector: 'app-dashboard',
template: `
<h1>Dashboard</h1>
<button (click)="exportReport()">Export PDF</button>
`
})
export class DashboardComponent {
// This is resolved eagerly — the service is created when the component is created
private readonly pdfService = inject(PdfReportService);
exportReport() {
this.pdfService.generate(this.getReportData());
}
}
Every time DashboardComponent is created, PdfReportService is instantiated — regardless of whether the user ever touches that export button. In a large application, this adds up.
Introducing injectAsync(): Lazy DI on Demand
Angular 22 ships injectAsync() to solve exactly this. The function signature is simple: you pass it a factory function that returns a Promise resolving to the service instance. Angular handles the DI resolution for you, including respecting the injection context — you are not reaching outside the DI system.
Here is the same dashboard rewritten with injectAsync():
// dashboard.component.ts
import { Component, injectAsync } from '@angular/core';
import { PdfReportService } from './pdf-report.service';
@Component({
selector: 'app-dashboard',
template: `
<h1>Dashboard</h1>
<button (click)="exportReport()">Export PDF</button>
`
})
export class DashboardComponent {
// The service is NOT resolved until exportReport() is called
private readonly getPdfService = injectAsync(() => import('./pdf-report.service')
.then(m => m.PdfReportService));
async exportReport() {
const pdfService = await this.getPdfService();
pdfService.generate(this.getReportData());
}
private getReportData(): ReportData {
return { /* ... */ };
}
}
What injectAsync() returns is not the service itself — it returns a function. Calling that function returns a Promise that resolves to the service. Angular ensures the instance is created within the correct injection context, so all the DI guarantees you expect still apply.
The key behavioral difference: the service (and its dependency graph) is only resolved the first time exportReport() is called. For users who never click that button, that chunk of code never loads.
How injectAsync() Works Under the Hood
You might be wondering: if injectAsync() returns a function that returns a Promise, how does Angular know to use DI for the resolved service?
The answer is that injectAsync() captures the injection context at the time it is called — which is during component construction, inside the injection context. The actual resolution is deferred, but the context reference is preserved. This is the same mechanism that allows things like toSignal() to work when called during component initialization.
This also means there is one important constraint: you must call injectAsync() during component construction (or in another valid injection context), not inside a method or lifecycle hook that runs later. The returned function can be called anywhere and at any time.
// CORRECT: called during construction
export class MyComponent {
private readonly lazyService = injectAsync(() =>
import('./heavy.service').then(m => m.HeavyService)
);
}
// WRONG: called inside a method — will throw
export class MyComponent {
someMethod() {
// This will throw — no injection context here
const lazyService = injectAsync(...);
}
}
Real-World Use Cases
Let me walk through a few scenarios where injectAsync() earns its keep.
Use Case 1: Feature-Gated Admin Tools
Imagine a component that most users never interact with — say, a bulk data export panel visible only to admins. The export logic imports a heavy CSV serializer and a custom reporting engine.
// admin-panel.component.ts
import { Component, injectAsync, signal } from '@angular/core';
@Component({
selector: 'app-admin-panel',
template: `
<section>
<h2>Admin Tools</h2>
@if (isExporting()) {
<p role="status">Generating export...</p>
}
<button (click)="runExport()" [disabled]="isExporting()">
Export All Records
</button>
</section>
`
})
export class AdminPanelComponent {
protected readonly isExporting = signal(false);
private readonly getExportService = injectAsync(() =>
import('../services/bulk-export.service').then(m => m.BulkExportService)
);
async runExport() {
this.isExporting.set(true);
try {
const exportService = await this.getExportService();
await exportService.run();
} finally {
this.isExporting.set(false);
}
}
}
For 95% of users who never visit the admin panel, the BulkExportService never loads.
Use Case 2: On-Demand Map Integration
A logistics dashboard shows a table of shipments by default, with an optional map view that loads a mapping SDK.
// shipment-tracker.component.ts
import { Component, injectAsync, signal } from '@angular/core';
@Component({
selector: 'app-shipment-tracker',
template: `
<div class="toolbar">
<button (click)="toggleView()">
@if (showingMap()) { Show Table } @else { Show Map }
</button>
</div>
@if (showingMap()) {
<div id="map-container"></div>
} @else {
<app-shipment-table />
}
`
})
export class ShipmentTrackerComponent {
protected readonly showingMap = signal(false);
private readonly getMapService = injectAsync(() =>
import('../services/map-renderer.service').then(m => m.MapRendererService)
);
async toggleView() {
if (!this.showingMap()) {
const mapService = await this.getMapService();
mapService.initialize('map-container');
}
this.showingMap.update(v => !v);
}
}
The mapping SDK and its associated bundle — which can be several hundred kilobytes — only loads the first time the user requests the map view.
Comparing inject() and injectAsync() Side by Side
Here is a direct comparison so the distinction stays clear:
| Aspect | inject() | injectAsync() |
|---|---|---|
| Resolution timing | Eagerly at component creation | Lazily on first call |
| Return value | The service instance | A function returning Promise |
| Bundle impact | Included in initial bundle | Split into a lazy chunk |
| Usage context | Construction or injection context | Must be called in injection context; result usable anywhere |
| DI guarantees | Full | Full (context is captured) |
| Use when | Service always needed | Service conditionally needed |
What pattern are you currently using for deferred feature loading in Angular? Drop a comment below — I am curious whether you have been reaching for lazy-loaded routes, dynamic imports, or something else entirely. This is exactly the kind of trade-off worth discussing.
Unit Testing Components That Use injectAsync()
Testing injectAsync() requires a slightly different approach than testing inject(). The key is that the lazy factory returns a Promise, so your tests need to handle async resolution.
First, let us look at the service we want to mock:
// bulk-export.service.ts
import { Service } from '@angular/core';
@Service()
export class BulkExportService {
async run(): Promise<void> {
// heavy export logic
}
}
Now here is how to write a unit test for the AdminPanelComponent:
// admin-panel.component.spec.ts
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { AdminPanelComponent } from './admin-panel.component';
import { BulkExportService } from '../services/bulk-export.service';
describe('AdminPanelComponent', () => {
let fixture: ComponentFixture<AdminPanelComponent>;
let component: AdminPanelComponent;
let mockExportService: jasmine.SpyObj<BulkExportService>;
beforeEach(async () => {
mockExportService = jasmine.createSpyObj('BulkExportService', ['run']);
mockExportService.run.and.returnValue(Promise.resolve());
// Intercept the dynamic import by providing the service in the test module
await TestBed.configureTestingModule({
imports: [AdminPanelComponent],
providers: [
{ provide: BulkExportService, useValue: mockExportService }
]
}).compileComponents();
fixture = TestBed.createComponent(AdminPanelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should show exporting state while export runs', fakeAsync(async () => {
const exportPromise = component.runExport();
fixture.detectChanges();
// isExporting should be true during the async operation
expect(component['isExporting']()).toBeTrue();
await exportPromise;
fixture.detectChanges();
expect(component['isExporting']()).toBeFalse();
}));
it('should call BulkExportService.run() when export is triggered', async () => {
await component.runExport();
expect(mockExportService.run).toHaveBeenCalledTimes(1);
});
it('should reset isExporting to false even if export throws', async () => {
mockExportService.run.and.returnValue(Promise.reject(new Error('Export failed')));
try {
await component.runExport();
} catch {
// expected
}
fixture.detectChanges();
expect(component['isExporting']()).toBeFalse();
});
});
A few things to note about this testing approach. Because injectAsync() captures the injection context at construction time and resolves through Angular's DI system, your TestBed.configureTestingModule providers are respected. You do not need to mock the dynamic import itself — providing the service in the test module is sufficient. The fakeAsync and async utilities both work here depending on the scenario.
injectAsync() and the New @Service Decorator
Angular 22 also ships the @Service() decorator, which is essentially a shorthand for @Injectable({ providedIn: 'root' }). One requirement of injectAsync() is that the lazily injected service must be auto-provided — either with @Service() or with @Injectable({ providedIn: 'root' }).
// analytics.service.ts
import { Service } from '@angular/core';
@Service()
export class AnalyticsService {
trackEvent(name: string, data: Record<string, unknown>) {
// heavy analytics logic
}
}
If you try to use injectAsync() with a service that is provided only at the component level or module level, you will run into issues because the DI resolution path for lazy injection relies on the root injector. Keep this constraint in mind when designing services you intend to lazy-inject.
As noted in the Angular 22 release coverage from Ninja Squad, injectAsync() requires the service to be auto-provided, either with @Service() or with @Injectable({ providedIn: 'root' }).
Bundle Size: The Real Payoff
The most concrete benefit of injectAsync() is what it does to your initial bundle. When you use a regular dynamic import() inside a method, most bundlers (including esbuild and webpack) will create a separate chunk for that imported module. injectAsync() plugs directly into that same mechanism — the factory function you pass to it typically contains a dynamic import, which gives your bundler the signal it needs to split that code into a lazy chunk.
The practical result: code that was previously part of your main bundle (because the service was eagerly injected at the top of your component) now lives in a separate chunk that only downloads when the feature is actually used.
For a service backed by a large third-party library — a PDF engine, a charting library, a rich text editor — the savings can be measured in hundreds of kilobytes off your initial load.
Bonus Tips
Tip 1: Cache the resolved instance yourself if you want synchronous access later.
injectAsync() always returns a Promise, even on subsequent calls. If you need synchronous access after the first resolution, store the result in a signal or a class property.
private resolvedService: HeavyService | null = null;
private readonly getHeavyService = injectAsync(() =>
import('./heavy.service').then(m => m.HeavyService)
);
async ensureService(): Promise<HeavyService> {
if (!this.resolvedService) {
this.resolvedService = await this.getHeavyService();
}
return this.resolvedService;
}
Tip 2: Combine with OnPush and Signals for maximum efficiency.
With OnPush now the default in Angular 22, components only re-render when their signal inputs change or when you explicitly trigger change detection. Pair injectAsync() with signals to communicate loading state, and your component will only repaint when it actually needs to.
Tip 3: Do not over-lazy-load.
injectAsync() is powerful but it is not a hammer for every nail. Services that are virtually always needed — routing, auth, core data — should stay as regular inject() calls. The overhead of a Promise resolution and a network request (for the lazy chunk) is only worth it when the service is genuinely conditional or infrequently needed.
Tip 4: Use TypeScript's type inference.
injectAsync() is fully typed. The returned function's return type is inferred from the factory's Promise type, so you get full autocomplete on the resolved service without any casting.
Tip 5: Watch the Angular 22 migration guide before upgrading.
Angular 22 introduces OnPush as the default, which means existing components without an explicit change detection strategy will behave differently after the upgrade. The ng update command automatically adds changeDetection: ChangeDetectionStrategy.Eager to components that do not specify a strategy, preserving their previous behavior during the upgrade. Run the migration before deploying.
Recap
Let us bring it all together. Angular 22's injectAsync() gives you a first-class, DI-aware way to defer service resolution until the moment you actually need it. Here is what we covered:
-
The problem:
inject()resolves dependencies eagerly at component creation, pulling large services and their dependency graphs into the initial bundle even when they are only conditionally needed. -
The solution:
injectAsync()accepts a factory function (typically wrapping a dynamic import) and returns a function that, when called, resolves to the service via Angular's DI system. -
The constraint:
injectAsync()must be called during component construction (in an injection context), and the target service must be auto-provided via@Service()or@Injectable({ providedIn: 'root' }). - The payoff: Conditionally needed services are code-split into lazy chunks, reducing initial bundle size for features that only a subset of users ever trigger.
-
Testing: Providing the service in
TestBed.configureTestingModuleis sufficient — Angular's DI respects test providers even for lazy-injected services.
Angular 22 continues a pattern that the team has been building toward for several versions: a framework where every performance best practice is also the ergonomic default. injectAsync() fits that vision well.
What did you think?
Did this approach match how you are solving lazy loading in your Angular apps, or do you have a different take? Drop a comment — I genuinely read every single one. Specifically: are there services in your current codebase that you are already thinking about converting to injectAsync()?
Found this helpful?
If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference.
Want more tips like this?
I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one.
Let us connect — find me on LinkedIn or GitHub and let us keep the conversation going.
Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
- ✍️ Reddit** — Developer blog posts & tech discussions
Top comments (0)