Skip to content
Angular ng patterns 4 min read

Singleton Service Pattern

A singleton is a service for which exactly one instance exists across the entire application, and every consumer that injects it receives that same shared object. In Angular this falls out naturally from the dependency injection system: when a service is registered with the root injector, the framework constructs it lazily once and caches it forever. This pattern is the backbone of cross-component state, caching layers, and any service that must coordinate behavior app-wide.

Creating a singleton with providedIn: root

The idiomatic way to declare an application-wide singleton is the providedIn: 'root' option on the @Injectable decorator. This registers the service with the root injector without you having to list it in any providers array.

import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly items = signal<string[]>([]);

  readonly count = this.items.asReadonly();

  add(sku: string): void {
    this.items.update((current) => [...current, sku]);
  }

  clear(): void {
    this.items.set([]);
  }
}

Any component or other service that injects CartService shares the same instance and therefore the same items signal. Add an item in one component and a second component reading count updates immediately.

import { Component, inject } from '@angular/core';
import { CartService } from './cart.service';

@Component({
  selector: 'app-product',
  standalone: true,
  template: `
    <button (click)="cart.add('SKU-42')">Add to cart</button>
    <p>Items in cart: {{ cart.count().length }}</p>
  `,
})
export class ProductComponent {
  protected readonly cart = inject(CartService);
}

Prefer providedIn: 'root' over listing services in a component or module providers array. It is tree-shakable: if nothing injects the service, it is dropped from the bundle entirely.

Why root services are tree-shakable singletons

When you use providedIn: 'root', Angular ties the provider to the service class itself rather than to a module declaration. The compiler can statically determine whether the class is reachable. The single instance is created the first time it is injected, not at startup, so unused services cost nothing.

RegistrationScopeTree-shakableLazy created
providedIn: 'root'App-wide singletonYesYes
providedIn: 'platform'Shared across apps on the pageYesYes
Component providers: [...]New instance per componentNoOn component creation
Route providers: [...]One instance per lazy route treeNoOn route load

The caveats: when “singleton” stops being singleton

The single-instance guarantee holds only for a single injector. Listing the same service in a component’s providers array creates a brand-new instance scoped to that component and its children, which silently shadows the root singleton.

@Component({
  selector: 'app-checkout',
  standalone: true,
  providers: [CartService], // new, separate instance — shadows the root one
  template: `...`,
})
export class CheckoutComponent {
  private readonly cart = inject(CartService); // NOT the shared cart
}

Two other situations break the assumption:

  • Lazy-loaded routes with their own providers. A service provided on a lazy route gets a child injector, producing a distinct instance per loaded route tree.
  • Server-side rendering. With SSR each incoming request gets a fresh injector. A “singleton” therefore lives only for one request, which is exactly what you want for per-user state but a trap if you cached something assuming process-wide lifetime.

Storing per-request data in a field while assuming it survives across requests is a classic SSR bug. State held in a root service is reset for every render request, so never treat it as a global cache that outlives a single user’s request.

Initialization and avoiding circular dependencies

Because singletons are created lazily and cached, do expensive setup in the constructor or a dedicated init method rather than recreating work on each call.

@Injectable({ providedIn: 'root' })
export class ConfigService {
  private readonly http = inject(HttpClient);
  readonly settings = signal<AppSettings | null>(null);

  load(): Observable<AppSettings> {
    return this.http.get<AppSettings>('/api/config').pipe(
      tap((cfg) => this.settings.set(cfg)),
    );
  }
}

If two singletons inject each other you create a circular dependency that throws at injection time. Break the cycle by extracting shared logic into a third service, or by injecting lazily via Injector.get() inside a method instead of in the constructor.

Output:

NG0200: Circular dependency in DI detected for CartService

Best practices

  • Default to providedIn: 'root' for any service meant to be shared app-wide; it is the most tree-shakable and least error-prone option.
  • Only put a service in a component or route providers array when you deliberately want a scoped, non-singleton instance.
  • Expose state through signals or read-only observables and keep mutations behind methods so the shared instance has a controlled API.
  • Treat root services as per-request, not per-process, when running under SSR — never cache cross-user data in them.
  • Keep singletons free of circular dependencies; refactor shared logic into a lower-level service if two singletons need each other.
  • Do heavy initialization once (constructor or an init() call), and let the cached instance serve every later consumer.
Last updated June 14, 2026
Was this helpful?