Understanding Service Provision in Angular

Angular's @Injectable decorator marks a class as eligible for injection through the framework's dependency injection mechanism. This allows the service to consume other dependencies that are registered within the DI container.

Applying the decorator to a class is straightforward:

@Injectable()
class MyService {
}
Enter fullscreen mode Exit fullscreen mode

Yet the presence of the decorator alone does not guarantee that Angular's injector will instantiate the service. The service must be explicitly registered with the DI system. Below, we examine the distinct strategies for making a service available to consumers.

ProvidedIn: 'root' vs. Registering Services in the Bootstrap Provider Array

For teams still working with NgModule, the bootstrap provider array in main.ts serves the same purpose as the provider array in your AppModule.

Both approaches guarantee a singleton service—a single shared instance across the whole application that can be injected anywhere.

The real distinction lies in how and where Angular optimizes the bundling of our service. We will explore this through five different scenarios.

  • Bootstrap provider array with the service never used
  • Bootstrap provider array with the service used in a single component
  • ProvidedIn 'root' with the service never used
  • ProvidedIn 'root' with the service used in a single component
  • ProvidedIn 'root' with the service used across multiple components

For this demonstration, we have set up three components: AppComponent, FooComponent, and BarComponent. Both FooComponent and BarComponent are loaded lazily via route navigation. Angular will produce three distinct bundles, and we will use source-map-explorer to identify which bundle contains MyService.

export const routes: Routes = [
  {
    path: 'foo',
    loadComponent: () => import('./foo.component').then((x) => x.FooComponent),
  },
  {
    path: 'bar',
    loadComponent: () => import('./bar.component').then((x) => x.BarComponent),
  },
];
Enter fullscreen mode Exit fullscreen mode

The objective is to observe how the Angular compiler tree-shakes our service.

Tree-shaking is the practice of eliminating dead code from a bundle to keep its size as small as possible.

1- Registering the Service in the Bootstrap Provider Array, Even When Unused

Let's begin by adding our service to the bootstrap application provider array. (The service will remain unused.)

bootstrapApplication(AppComponent, {
  providers: [
    MyService, 
    provideRouter(routes)
  ],
});
Enter fullscreen mode Exit fullscreen mode

First, we need to generate our source map files by building the application to be able to analyze them with source-map-explorer:

ng build -c development
Enter fullscreen mode Exit fullscreen mode

result of ng build

The build output reveals that Angular generated two lazy chunks and the primary build chunk. Our attention is on those lazy chunks and main.js.

Let's take a closer look at our bundles using source-map-explorer:

source-map-explorer ./dist/[my-application-name]/*.js
Enter fullscreen mode Exit fullscreen mode

source-map-explorer visualization

Our service is embedded in the main bundle, despite being completely unused. This forces users to download code they will never execute.

2- Registering the Service in the Bootstrap Provider Array and Using it in a Single Lazy-loaded Component

The result remains identical. Even if the service is only consumed by one lazy-loaded component, it is still placed in the main bundle.

3- ProvidedIn 'root' with an Unused Service

The ProvidedIn property within the @Injectable decorator lets us define which injector should be responsible for creating our service.

@Injectable({ providedIn: 'root' })
export class MyService {}
Enter fullscreen mode Exit fullscreen mode

Specifying providedIn: 'root' registers the service at the application's root level. This appears similar to the previous setup, but the Angular compiler is now able to eliminate the service from the final bundles depending on its usage.

result of ng build 2

Comparing this build with the previous one, the main.js bundle shrunk from 1018B to 874B, while bar.js and foo.js stayed the same. Because MyService was never injected, the Angular compiler omitted it from every bundle.

4- ProvidedIn 'root' with the Service Used in a Single Component

Let's now inject the service into BarComponent and rebuild the application.

@Component({
  selector: 'app-bar',
  standalone: true,
  imports: [],
  template: `bar`,
})
export class BarComponent {
    service = inject(MyService)
}
Enter fullscreen mode Exit fullscreen mode

result of ng build 3

The bar.js bundle grew in size, which shows that since MyService is only needed by BarComponent, the compiler co-located the service and the component in the same chunk.

This is also visible through map-source-explorer.

map source explorer result 2

5- ProvidedIn 'root' with the Service Used Across Multiple Lazy-loaded Components

What happens when the service is required by several lazy-loaded components? Let's also inject it into FooComponent.

result of ng build 4

Angular then creates a third chunk, common.js, which contains our service. This approach prevents code duplication. The first component to load will trigger the download of common.js.

Chunk sizes are slightly larger now because we have included the constructor declaration in our compiled JS file.

Conclusion

For every singleton service, always go with ProvidedIn: 'root'. It's cleaner to maintain and lets the Angular compiler efficiently tree-shake our bundles in the most optimal manner.

Services with Multiple Instances

To obtain several independent instances of the same service, the ProvidedIn property must be left undefined (or simply omitted), and the service needs to be registered in the Component's providers Array.

@Component({
  selector: 'app-bar',
  standalone: true,
  imports: [],
  providers: [MyService], // provide a service
  template: `bar`,
})
export class BarComponent {
  service = inject(MyService)
}
Enter fullscreen mode Exit fullscreen mode

The service instance is now bound to the lifecycle of the component. When the component is created or removed, the service follows suit. If two separate components are instantiated, each will receive its own distinct instance of MyService.

Practice Problems

Problem 1

@Injectable()
export class MyService {
  title = 'No Title';

  setTitle = (title: string) => (this.title = title);
}

@Component({
  selector: 'child',
  standalone: true,
  template: `<div>{{ myService.title }}</div>`,
})
export class ChildComponent {
  myService = inject(MyService);
}

@Component({
  selector: 'parent',
  standalone: true,
  imports: [ChildComponent],
  providers: [MyService],
  template: `<child></child>`,
})
export class ParentComponent {
  myService = inject(MyService).setTitle('Parent Title');
}
Enter fullscreen mode Exit fullscreen mode

Question: What output appears on the screen?
Answer: Parent Title
Reasoning: When a service is registered in the parent component's providers array, every child component under that parent shares the exact same instance. This pattern is ideal for distributing shared state among a subtree of components.
 

Problem 2

@Injectable()
export class MyService {
  title = 'No Title';

  setTitle = (title: string) => (this.title = title);
}

@Component({
  selector: 'child',
  standalone: true,
  providers: [MyService], // provide MyService in child component
  template: `<div>{{ myService.title }}</div>`,
})
export class ChildComponent {
  myService = inject(MyService);
}

@Component({
  selector: 'parent',
  standalone: true,
  imports: [ChildComponent],
  providers: [MyService],
  template: `<child></child>`,
})
export class ParentComponent {
  myService = inject(MyService).setTitle('Parent Title');
}
Enter fullscreen mode Exit fullscreen mode

Question: What output appears on the screen?
Answer: No Title
Reasoning: If MyService is registered in both the parent and the child component, two separate instances of MyService will exist. (The inner workings of DI will be covered in a separate article)

Route-Level Registration

Services can also be registered directly within a route's providers array.

This approach is equivalent to registering the service within a lazy-loaded NgModule.

export const routes: Routes = [
  {
    path: 'bar',
    providers: [MyService],
    loadChildren: () => import('./child/routes').then((r) => r.childRoutes),
  },
  {
    path: 'foo',
    loadComponent: () => import('./foo.component').then((x) => x.FooComponent),
  },
];
Enter fullscreen mode Exit fullscreen mode

Here, childRoutes represents a subset of the overall routing configuration.

export const childRoutes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'bar-1' },
  {
    path: 'bar-1',
    loadComponent: () =>
      import('./bar-child-1.component').then((x) => x.BarChild1Component),
  },
  {
    path: 'bar-2',
    loadComponent: () =>
      import('./bar-child-2.component').then((x) => x.BarChild2Component),
  },
];
Enter fullscreen mode Exit fullscreen mode

This behavior may initially seem counterintuitive. MyService is created upon the first visit to any route nested under bar-child. Leaving that route does not dispose of the service. Returning to the route will not cause another instantiation—only a single instance of MyService persists in memory.

Components that are not descendants of this route cannot access MyService.

At compile time, MyService is bundled into main.js (rather than being placed in a separate lazy-loaded chunk, as originally expected). Note that this differs from registering the service in a parent or child component, where instantiation and destruction are tied to the component's lifecycle.

result of ng build 5

source map explorer visualization 3


Pro Tip: A service that is created upon navigating to a specific route and removed when navigating away can be achieved by merging the concepts from the previous two sections.

For a service to be destroyed, it must be registered within a component decorator. Yet to be instantiated on route entry, the service must be provided at the route level. The solution is to introduce a parent component that manages the service instance.

{
  path: 'bar',
  loadComponent: () =>
    import('./my-service.component').then((x) => x.MyServiceComponent),
  loadChildren: () => import('./child/routes').then((r) => r.childRoutes),
},
Enter fullscreen mode Exit fullscreen mode
@Component({
  standalone: true,
  imports: [RouterOutlet],
  providers: [MyService],
  template: `<router-outlet></router-outlet>`,
})
export class MyServiceComponent {}
Enter fullscreen mode Exit fullscreen mode

In this setup, MyServiceComponent is supplied with an instance of MyService. Navigating to bar triggers the creation of the child component, which inherits the same MyService instance. Upon leaving the route, the child component is destroyed, and with it, the associated instance of MyService.


This concludes the guide—you're now equipped to control and optimize service injection in Angular with confidence.

If you found this material useful, you can connect with me on Twitter or Github.

👉 For hands-on practice to fast-track your Angular and Nx expertise, explore Angular challenges.