Singleton Services
A singleton service is a class that Angular instantiates exactly once and then shares with every part of your application that asks for it. Singletons are the natural home for state and behaviour that must be consistent everywhere — the current user, a shopping cart, a feature-flag cache, or a wrapper around HttpClient. The modern, tree-shakable way to create one is providedIn: 'root', and getting it right means understanding which injector holds your instance.
What providedIn: 'root' actually does
When you decorate a service with @Injectable({ providedIn: 'root' }), you register a provider on the root environment injector — the single injector created when your app bootstraps. The first time any component, directive, guard, or other service injects the token, Angular creates one instance and caches it on that injector. Every subsequent request returns the same instance.
Because the provider lives on the root injector and the injector lives for the entire lifetime of the application, the service is effectively a global singleton — but a tree-shakable one. If nothing in your bundle ever injects the service, the optimizer drops it from the output entirely. This is why providedIn: 'root' is preferred over the older pattern of listing services in a module’s providers array.
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly items = signal<string[]>([]);
readonly count = computed(() => this.items().length);
add(sku: string): void {
this.items.update((list) => [...list, sku]);
}
clear(): void {
this.items.set([]);
}
}
Sharing one instance across components
Any standalone component can inject the service with inject() and they will all observe the same state. The header and the product page below read and mutate the same CartService.
import { Component, inject } from '@angular/core';
import { CartService } from './cart.service';
@Component({
selector: 'app-header',
standalone: true,
template: `<span>Cart: {{ cart.count() }}</span>`,
})
export class HeaderComponent {
protected readonly cart = inject(CartService);
}
@Component({
selector: 'app-product',
standalone: true,
template: `<button (click)="cart.add('SKU-42')">Add to cart</button>`,
})
export class ProductComponent {
protected readonly cart = inject(CartService);
}
Click the button once and the header immediately shows Cart: 1 because the signal lives on the one shared instance. No inputs, outputs, or event bus required.
Output:
Cart: 0
(user clicks "Add to cart")
Cart: 1
Proving it is a single instance
A quick way to confirm a service is a true singleton is to log a unique id from its constructor and inject it in two places.
import { Injectable, inject, Component } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class IdService {
readonly id = Math.random().toString(36).slice(2, 8);
constructor() {
console.log('IdService created:', this.id);
}
}
@Component({ selector: 'app-a', standalone: true, template: '' })
export class AComponent {
constructor() {
console.log('A sees', inject(IdService).id);
}
}
Output:
IdService created: k3p9za
A sees k3p9za
B sees k3p9za
The constructor runs once; both components see the identical id.
Common pitfalls
Singletons are simple until they aren’t. The table below covers the mistakes that bite teams most often.
| Pitfall | What goes wrong | Fix |
|---|---|---|
Listing the service in a component’s providers | A new instance is created per component, silently breaking shared state | Rely on providedIn: 'root' only |
| Importing the service from two different file paths | Two distinct classes, two singletons | Use one canonical import path / barrel |
| Lazy route also provides the service | The lazy-loaded child injector shadows the root one | Don’t re-provide root services in route providers |
| Mutable public fields | Any consumer can corrupt shared state | Expose readonly signals + methods |
Gotcha: Adding your “global” service to a route’s or component’s
providersarray does not make it more available — it creates a second, scoped instance that shadows the singleton. If a feature appears to “forget” data, this is almost always the cause.
Tip: Singletons live for the whole app, so they never get destroyed. Anything they subscribe to or
setIntervalthey start will leak for the session — tear those down or prefer signals, which require no cleanup.
Singletons vs. scoped services
Reach for a singleton when the data is genuinely application-wide. When state belongs to one screen or widget, register the provider lower in the tree instead, so it is created and discarded with that part of the UI.
| Need | Use |
|---|---|
| Auth/session, app config, caches, HTTP wrappers | providedIn: 'root' singleton |
| State for one feature area | Route-level providers |
| State isolated per component instance | Component providers |
Best Practices
- Default to
providedIn: 'root'for any service intended to be shared application-wide — it is tree-shakable and needs zero module wiring. - Never list a root singleton in a component or route
providersarray; doing so creates an unintended second instance. - Import each service from a single canonical path to avoid duplicate class identities and accidental duplicate singletons.
- Expose state through
readonlysignals and methods rather than public mutable fields so consumers cannot corrupt shared data. - Avoid long-lived subscriptions and timers in singletons; prefer signals and
computedso there is nothing to clean up. - Keep singleton APIs small and intentional — a global service is a global dependency, so resist turning it into a junk drawer.