In an Angular app, the header's cart badge stays at zero while the lazy checkout route shows items — how can CartService end up with two instances, and how do you fix it?
answer
- count the providers, not the classes
- a second registration below root
- route injectors shadow root
- lazy NgModule and providedIn any
- remove the extra entry
basics
~20 sCartService is also registered below root — in the checkout route's providers, a lazily loaded NgModule's providers, a component's providers, or via the deprecated providedIn: 'any' — so checkout gets its own instance while the header uses root's. Remove that extra registration.
solid answer
~40 sA root singleton is only single **per injector that provides it**. If `CartService` is `providedIn: 'root'` (or `@Service()`) but someone also lists it in the lazy `checkout` route's `providers`, the router's child environment injector for that route creates a second instance, and every checkout component finds it first; the header, outside the route, keeps using root's. The same happens with a lazily loaded NgModule whose `providers` (or an imported shared module's) include the class, with the deprecated `providedIn: 'any'`, or with a checkout component's `providers`. Confirm it with Angular DevTools' injector tree or a constructor log, then delete the extra registration so only root provides the class. If the route needs configuration, provide a token rather than the service.
code
ts · 24 linesimport {Routes} from '@angular/router';
import {Injectable, signal} from '@angular/core';
@Injectable({providedIn: 'root'})
export class CartService {
readonly items = signal<string[]>([]);
constructor() {
console.debug('CartService created'); // logs twice when duplicated
}
}
export const routes: Routes = [
{
path: 'checkout',
// BUG: creates a second CartService in the route's environment injector
providers: [CartService],
loadChildren: () => import('./checkout/checkout.routes'),
},
];
// Fix: delete the providers entry; the root instance is already visible here.
export const fixedRoutes: Routes = [
{path: 'checkout', loadChildren: () => import('./checkout/checkout.routes')},
];go deeper
Recall that a root service is shared, and that listing it again in another providers array creates a second copy.
Explain how a route or lazy module's injector shadows root for its subtree, and list the registrations that cause it.
Diagnose with DevTools or instance logging, remove the stray registration, and add conventions and tests that keep stateful services root-only.
Define ownership rules for shared state across lazily loaded features so no team re-provides app-wide services, and make those rules checkable in review.
## The symptom The shop's header shows a cart badge. The checkout feature is a lazily loaded route. Users add items on the checkout pages, the checkout summary shows them — and the header badge stays at zero. Navigating away and back keeps the checkout's items, because by default route injectors are not destroyed when you navigate away, so the bug looks like a synchronisation problem rather than a scoping one. ## Why two instances exist "Singleton" in Angular means **one instance per injector that has a provider for the class**. `providedIn: 'root'` gives one instance in the root environment injector. Any additional registration lower in the hierarchy creates **another** instance, and lookups from below find the closer one first. The usual culprits: 1. **Route providers.** The `checkout` route lists `providers: [CartService]`, perhaps added "so checkout can use it". The router creates a child environment injector for the route, which builds its own `CartService`. 2. **A lazily loaded NgModule.** In NgModule-based code, a lazy feature module — or a shared module it imports — has `providers: [CartService]`. Lazy modules get a child module injector, which again creates its own instance. (This is the classic reason `forRoot()` patterns exist.) 3. **`providedIn: 'any'`** — deprecated — deliberately gives each lazy injector its own instance. 4. **Component providers.** A checkout shell component lists `CartService` in `providers`, giving its subtree a private copy. Once a second instance exists, both sides behave correctly given what they see: checkout writes to its instance, the header reads root's. ## Confirming it - **Angular DevTools** injector tree: select a checkout component and the header, and compare where `CartService` is resolved — root versus a route injector (or a component injector). - **A constructor log** or instance counter in `CartService`: two log lines prove two instances. - **Search the code** for every place the class appears in a `providers` array, an NgModule, or with `providedIn: 'any'`. ## Fixing it | Cause | Fix | |---|---| | Route `providers` entry | Remove it; root provision already makes the service available to lazy routes | | NgModule `providers` in a lazy or shared module | Remove it, or provide it only in the root configuration | | `providedIn: 'any'` | Change to `'root'` or `@Service()` | | Component `providers` entry | Remove it, unless per-component state was the intent | If the route genuinely needs different behaviour, provide a **configuration token** at the route (for example currency or tax rules) and let the single root `CartService` read it through a method argument or a separately injected setting — do not re-provide the stateful service. ## Preventing it - Keep **stateful app-wide services** root-provided only, and never list them in arrays; reviewers can treat a root service appearing in `providers` as a red flag. - Declare classes that are **meant** for route or component scope with `@Service({autoProvided: false})` or `@Injectable()` so the two intents are visually distinct. - Add a test that injects `CartService` from a component inside the lazy route and from the root, and asserts they are the same instance. ## Interview follow-through Interviewers often push further: - **"Why did someone add it to the route in the first place?"** — frequently a misunderstanding that lazy routes cannot see root services, or a copy-paste from an NgModule era where feature modules listed their services. - **"What if checkout really needs its own cart?"** — then make that explicit: a separately named, non-auto-provided `CheckoutDraftCart` in the route's `providers`, so no one confuses it with the app-wide cart. - **"How do you stop it recurring?"** — the conventions above, plus a lint rule or code-review checklist item for root services in `providers` arrays. ## Why lazy loading is not the problem Lazy loading by itself does **not** duplicate root services: a root-provided class imported from a lazy chunk is still created by the root injector. Duplication always comes from an extra registration in an injector below root. Knowing that distinction is what interviewers are checking.
- Does lazy loading a route by itself create a new instance of a providedIn root service?No. A root-provided class is created by the root injector no matter which chunk its code arrives in, so lazy routes share the same instance. Duplicates only appear when a lower injector — a route, a lazily loaded NgModule, a component, or providedIn 'any' — also provides the class.
- Why do the checkout items survive navigating away and back?By default, route environment injectors and the services they create persist after you navigate away; they are not destroyed until the application is. The duplicated CartService therefore keeps its state across visits, which makes the bug look like a synchronisation problem rather than two instances.
The shop has one till at the entrance, but the checkout department put its own cash box on the counter. Customers in checkout pay into the cash box, the entrance till never sees the money, and both staff swear their totals are right.
saying these in an interview costs you the question
- Lazy loading always creates new instances of root services
- providedIn: 'root' guarantees one instance even if listed elsewhere
- The fix is to sync the two instances with an event bus
- providedIn: 'any' is the recommended scope for shared services
- Route injectors are destroyed on every navigation away