Lazy loading is a familiar concept in Angular when it comes to modules or components. But have you ever considered lazy loading services? Surprising as it sounds, it's absolutely possible. This article walks through the process of lazy loading a service in Angular and highlights some important caveats.
The Concept of Lazy Loading
Lazy loading, also referred to as code splitting, is an optimization technique that defers the loading of code until it is actually required. This approach helps shrink the initial bundle size and speeds up the application's startup time. Dynamic import statements are the primary tool for implementing lazy loading.
As an illustration, a module can be lazy loaded in the following way:
import("./my-component").then((file) => {
// do something with the component
});
This tells the bundler to split the module into a separate chunk and fetch it only when that particular code path is executed.
Lazy Loading a Service in Angular
Naturally, the dynamic import syntax is involved here as well. However, it's not the whole story! Services in Angular come with the Injectable decorator, which means they participate in dependency injection and may rely on other services. Consequently, a service can't simply be lazy loaded and instantiated like a regular class.
The correct approach involves two steps: first, use dynamic import to fetch the service code, and second, leverage the injector to obtain the service instance. Here is an example of this pattern:
import("./my-service").then((file) => {
const service = this.injector.get(file.MyService);
// do something with the service
});
While this works, the developer experience (DX) leaves something to be desired, as you need to handle the injector manually. To improve this, a helper function can be created to encapsulate the lazy loading logic and directly return the service instance. The following helper accomplishes that:
export function lazyLoadService<T>(loader: () => Promise<T>): Promise<T> {
const injector = inject(Injector);
return loader().then((serviceClass) => {
const service = injector.get(serviceClass);
return service;
});
}
It's a good idea to switch the return type from a Promise to an Observable. Observables fit more naturally with Angular's reactive idioms and are easier to chain with other operations.
export function lazyService<T>(loader: () => Promise<Type<T>>): Observable<T> {
const injector = inject(Injector);
return defer(() => {
return loader().then((service) => injector.get(service));
});
}
The secret sauce here is the defer operator. It crafts an Observable that doesn't execute the loader function until a subscription happens. This means the actual service code won't be fetched until it's really needed. Here's how it's used:
lazyService(() => import("./my-service")).subscribe((service) => {
// do something with the service
});
An even cleaner approach is to pipe the service observable directly, treating it like any other stream.
lazyService(() => import("./my-service")).pipe(
concatMap((service) => {
// do something with the service
})
);
Let's put this into practice with a component example:
const DataServiceImport = () =>
import('./data.service').then((m) => m.DataService);
@Component({
template: `
<ul>
<li *ngFor="let todo of todos$ | async">
{{ todo.title }}
</li>
</ul>
`,
standalone: true,
imports: [NgFor, AsyncPipe],
})
export class AppComponent {
private dataService$ = lazyService(DataServiceImport);
todos$ = this.dataService$.pipe(concatMap((s) => s.getTodos()));
}
And now, let's take a look at the network tab!
Indeed, the service gets its own dedicated bundle, which is a cause for celebration 🎉!
However, there's a catch. If the service is needed in a different component, it will need to be lazy loaded again. If you don't do this and just import it directly, the bundler will include it in the main bundle when used in a non-lazy-loaded component, or in the common bundle if used by another lazy-loaded component, thereby breaking the desired code-splitting.
NOTE: The JavaScript bundle is downloaded only once upon first use. Subsequent lazy loading calls reuse the already downloaded chunk since the bundler (e.g., webpack) caches it. This means you don't have to worry about repeated network requests.
Alternative Strategies for Lazy Loading a Service
There are multiple approaches beyond the one shown above. A tweet thread by Younes elaborates on several different techniques:
Tweet 1: https://twitter.com/yjaaidi/status/1552281356608102404
Tweet 2: https://twitter.com/yjaaidi/status/1552570805715861504
Real-World Use Cases for Lazy Loading Services
Imagine a service that is only consumed by a single component that is itself lazy loaded. In this scenario, you would want to lazy load the service in tandem, ensuring it's only fetched when the component is actually rendered or the service method is invoked.
Another compelling scenario involves highly dynamic applications. Here, components are created on the fly, potentially driven by configuration files, and they might depend on a shared set of services. Since the required services aren't known upfront, including them all in the main bundle would bloat it significantly. Lazy loading each service as it's requested keeps the initial payload lean.
Summary
A special thanks to @Younes whose thorough investigation into lazy loading services inspired this article. By all means, connect with him on Twitter.
The post-Ivy world has simplified many Angular features, and lazy loading services is now a practical tool that fits right in.
Experiment with this pattern and see how much you can trim from your main bundle. Feel free to discuss your results in the comments!
You can try the code in this StackBlitz playground: https://stackblitz.com/edit/angular-jb85mb?file=src/main.ts 🎮
Thank you for reading!
I'm very active on Twitter about Angular topics, from the latest news and videos to RFCs and code updates. If that interests you, you can follow me at @Enea_Jahollari. Or follow me on dev.to for more articles in the future.



