For years, Angular has made lazy loading straightforward for routes and components, but services remained a blind spot. A service registered in the root injector landed in the main bundle and stayed there, regardless of how rarely its code path was executed.
That gap closes in Angular 22 with injectAsync, a utility that fetches a service only when you first request its instance. This piece explains the mechanics, the requirements, and the real-world payoff, using examples from an offer editor I built, so the file names and measurements are straight from the browser's dev tools.
The problem in brief
The app I'm talking about is a form for creating a sales offer. Users typically spend their time entering contractors, products, and conditions before saving a draft. Tucked at the bottom is a "Download PDF" button.
That button carries a heavy load. Behind it sits a service that renders the offer component into an offline host, captures a snapshot, and divides it into A4 pages. For that to work, it pulls in a PDF library and a DOM-to-image converter.
A normal inject() call bundles that export service into the initial payload, which every visitor downloads, even those who merely save a draft and leave.
Route-level lazy loading won't help here. The route is already active, so the service gets loaded along with the chunk the user has already received.
Before Angular 22, the go-to workaround was to obtain the Injector, manually write a dynamic import(), and then read the instance from the injector once the import resolved. It functioned, but the boilerplate was enough to deter most developers.
The new API: injectAsync
injectAsync is exported from @angular/core and has been stable since version 22. You pass it a loader that returns a promise resolving to the service class, and it gives back a function that resolves to the instance.
import { Component, injectAsync } from '@angular/core';
@Component({
selector: 'example-feature-new-offer-page',
templateUrl: './feature-new-offer-page.html',
})
export class FeatureNewOfferPage {
private readonly pdfExport = injectAsync(() =>
import('@example/util-pdf-export').then((m) => m.PdfExportService),
);
protected async onDownloadPdf(): Promise<void> {
const pdfExport = await this.pdfExport();
await pdfExport.exportComponentToPdf({
component: OfferDocument,
inputs: { data: this.readonlyData() },
fileName: `oferta-${this.offerId}.pdf`,
});
}
}
Two distinct processes are at work here.
The dynamic import() communicates with your bundler. It lifts @example/util-pdf-export out of the main bundle and emits it as its own JavaScript file, which no one downloads during startup.
The invocation of this.pdfExport() is the real trigger. It fetches that file, and once the code is available, Angular instantiates the service through ordinary dependency injection. The service itself is untouched: it retains its own injected dependencies, and the root injector still holds only a single instance. Angular maintains the promise it created, so a second click on the button incurs zero additional network overhead.
One subtlety is easy to overlook. injectAsync appears in a field initializer, not inside the click handler, because it needs an injection context like inject() does. Only the await belongs in the method body.
Requirement: the service must be auto-provided
This is where many developers stumble. When the chunk arrives, Angular needs a provider to use immediately. If the service doesn't register itself, there's nothing to build.
That means you need one of two decorators. Here's the familiar one:
@Injectable({ providedIn: 'root' })
export class PdfExportService { /* ... */ }
Or the newer, more concise form added in Angular 22:
import { Service } from '@angular/core';
@Service()
export class PdfExportService { /* ... */ }
@Service() places the class into the root scope without any arguments. It's the standard case, while @Injectable() remains for setups with custom providers. You can also disable automatic registration and provide the class elsewhere, like on a route or component:
@Service({ autoProvided: false })
export class TabRegistry { /* ... */ }
A class defined in that manner won't work with injectAsync, because there's no provider waiting on the other side of the import.
Default exports
If the lazy class is the default export of its file, the .then() step becomes unnecessary. You can pass the import directly, and Angular will read the default property itself:
@Service()
export default class PdfExportService { /* ... */ }
private readonly pdfExport = injectAsync(() => import('@example/util-pdf-export'));
Prefetching so the user does not wait
On-demand loading shifts the download to the least convenient moment — right after the click. The user gets a visible pause while the network catches up.
You can move the download earlier by supplying a prefetch option to injectAsync. This option must return a promise, and Angular runs the loader once that promise resolves.
The framework includes one built-in trigger. onIdle waits for a quiet phase in the browser:
import { Component, injectAsync, onIdle } from '@angular/core';
@Component({ /* ... */ })
export class FeatureNewOfferPage {
private readonly pdfExport = injectAsync(
() => import('@example/util-pdf-export').then((m) => m.PdfExportService),
{ prefetch: onIdle },
);
}
Some pages never settle down, for which you can cap the wait:
injectAsync(loader, { prefetch: () => onIdle({ timeout: 1_000 }) });
Nothing hinges on the prefetch completing in time. If a user clicks before the background download kicks off, they get the standard on-demand path, and the await resolves as soon as the code is ready. Think of the feature as a head start, not a prerequisite.
For adjusting idle detection globally, provideIdleServiceWith lets you swap out the underlying IdleService, often in app.config.ts.
A trigger on your own terms
Here's where the flexibility shines. A prefetch trigger is simply a function that returns a promise, and Angular never questions where that promise came from. Resolve it on a hover, on a scroll position, on a feature flag arriving from the server — anything you can observe. Idle time is just the default, not the only option.
For the PDF button, hovering is a far better signal than idleness. A pointer moving toward the button is a near-certain sign of intent.
The challenge is timing. A viewChild signal holds undefined until the view is rendered, and the trigger gets created before that happens. Additionally, the button lives inside an @if block that toggles between edit and preview modes, meaning the element can be destroyed and recreated multiple times.
So the trigger accepts a function rather than an element, and tracks it with an effect:
import {
effect,
ElementRef,
inject,
Injector,
type EffectRef,
type PrefetchTrigger,
} from '@angular/core';
type ElementSource = Element | ElementRef<Element> | undefined;
export interface ElementEventTriggerOptions {
/** Events to listen for. The first one to fire starts the prefetch. */
events?: readonly string[];
/** Required when the trigger is created outside an injection context. */
injector?: Injector;
}
export function onElementEvent(
target: () => ElementSource,
{
events = ['pointerenter', 'focusin'],
injector,
}: ElementEventTriggerOptions = {},
): PrefetchTrigger {
const ownInjector = injector ?? inject(Injector);
let pending: Promise<void> | undefined;
return () => (pending ??= waitForEvent(target, events, ownInjector));
}
function waitForEvent(
target: () => ElementSource,
events: readonly string[],
injector: Injector,
): Promise<void> {
let watcher: EffectRef | undefined;
return new Promise<void>((resolve) => {
watcher = effect(
(onCleanup) => {
const el = toElement(target());
if (!el) return;
const controller = new AbortController();
onCleanup(() => controller.abort());
const onEvent = () => {
watcher?.destroy();
resolve();
};
for (const name of events) {
el.addEventListener(name, onEvent, {
once: true,
signal: controller.signal,
});
}
},
{ injector },
);
});
}
function toElement(value: ElementSource): Element | undefined {
return value instanceof ElementRef ? value.nativeElement : value;
}
What happens, step by step
The injector is captured first. onElementEvent executes in a field initializer, so inject(Injector) is valid there. Saving it is required, because the effect gets created later inside a promise callback, where the injection context is absent. Passing it explicitly prevents Angular from throwing an error.
The returned function sits inert until Angular calls it. The resulting promise is stored in pending, so a second invocation returns the same promise instead of spawning a duplicate effect.
The effect waits for the element to appear. target() reads a signal. On the first run, it typically returns undefined, and the effect stops there, but Angular has already registered the dependency. When the view renders and the signal gets a value, the effect re-runs with a concrete element.
Listeners share an abort signal. Every event name in the list gets the same handler, and one controller.abort() removes them all. This is important because the list is configurable.
onCleanup handles both exits. It fires when the effect re-runs, which occurs when the preview toggle rebuilds the footer and produces a new button, and it fires again on component destruction. Old listeners never outlive the element they were bound to.
The event ends the cycle. The effect destroys itself, as there's nothing left to observe, and resolve() hands control back to Angular, which initiates the download. From there, the flow matches any other trigger.
focusin is listed next to pointerenter intentionally. Keyboard navigation never generates a hover, and without that second event, Tab users would be the only group left waiting.
Using it
<button
#pdfBtn
mat-flat-button
color="primary"
[disabled]="isExportingPdf()"
(click)="onDownloadPdf()"
>
<mat-icon>picture_as_pdf</mat-icon>
{{ isExportingPdf() ? 'Generating PDF…' : 'Download PDF' }}
</button>
// read: ElementRef is needed here. MatButton is a component, so a bare #pdfBtn
// would resolve to the MatButton instance instead of the DOM element.
private readonly pdfButton = viewChild('pdfBtn', {
read: ElementRef<HTMLElement>,
});
// The export is only needed after the click, so the chunk is fetched in the
// background as soon as the pointer or the keyboard focus reaches the button.
private readonly pdfExport = injectAsync(
() => import('@example/util-pdf-export').then((m) => m.PdfExportService),
{ prefetch: onElementEvent(() => this.pdfButton()) },
);
read: ElementRef<HTMLElement> seems odd at first because a type argument appears where a value is expected. It compiles fine. TypeScript permits type arguments on a generic constructor used as a value, and the signal ends up correctly typed as ElementRef<HTMLElement> | undefined.
Checking it in the browser
All of this shows up in the Network tab, which is the quickest way to confirm the split actually happened.

util-pdf-export-VT6G3IZ5.js arrives as a separate file, roughly 33 kB in the development build. It's absent from the initial page load and appears the moment the pointer touches the button. For a user who never opens the export, it's never requested at all.
Name your chunks so you can find them
Take a closer look at that file name: util-pdf-export-VT6G3IZ5.js. The readable part comes from the imported library, and the rest is a content hash for cache busting.
This is basic hygiene, and it pays off the first time you go hunting. A production build yields dozens of chunks, and half the task of verifying a lazy load is picking the right row from the list.
Naming the file or library to describe its contents turns that into a one-second check. util-pdf-export tells you what you're looking at. So does feature-invoice-preview. Keeping one heavy dependency per chunk helps for the same reason — a name only carries meaning if the contents match it.
One mistake to avoid
Splitting only works if nothing pulls the service back into the main bundle. A single ordinary import anywhere in the project is enough to keep the bundler from splitting, in which case the dynamic import achieves nothing.
Search the codebase for other imports of that module. If the class is only used for type annotations, switch to a type-only import, which gets stripped during compilation:
import type { PdfExportService } from '@example/util-pdf-export';
Then rebuild and inspect the Network tab again. A separate request and a smaller main bundle mean it worked.
When it is worth it
injectAsync pays off when a service is both heavy and seldom used. PDF generation fits the bill, as do charting libraries, rich text editors, map SDKs, spreadsheet exports, and analytics clients that only start after consent.
Small services aren't worth the split. A class with a handful of methods and no external dependencies weighs less than the network round trip required to fetch it, so you'd exchange a smaller bundle for a slower first use and messier code.
The other cost is the async boundary. Every caller must await the service, and that can ripple outward if the service is used throughout the component. The best candidates are those tucked behind a single user action, keeping the boundary contained.
Summary
Angular 22 turns lazy service loading from a manual workaround into a proper framework feature. Use injectAsync with a dynamic import, keep the service auto-provided through @Injectable({ providedIn: ‘root’ }) or @Service(), and throw in a prefetch trigger so the download starts before the user needs it.
onIdle covers the majority of cases. When you require something more precise, remember that a trigger is just a function returning a promise — and a hover on the button that needs the code is about as precise as it gets.
