What makes an Angular provider tree-shakable, and why is providedIn: 'root' tree-shakable while a providers-array entry is not?
answer
- which side holds the reference
- service points at the injector
- config array points at the class
- unused import vs unused class
basics
~20 sA provider is tree-shakable when the service registers itself (providedIn: 'root' or @Service()), so only code that injects it references the class. A providers-array entry references the class from configuration, so the bundler must keep it even if nothing injects it.
solid answer
~40 sBundlers drop code nothing references. With `providers: [ReportService]` in `ApplicationConfig`, a route or a component, the **configuration** references the class, so it stays in the bundle whether or not anything injects it. With `@Injectable({providedIn: 'root'})` or `@Service()`, the reference is inverted: the class carries its own injectable definition, including its factory and `providedIn: 'root'`, and the root injector consults that definition only when some code asks for the token. If no file imports and injects the class, nothing references it and the bundler removes it. That is why root-provided services, and `InjectionToken`s declared with `providedIn` and a `factory`, are the default for libraries: an app pays only for what it injects. Component providers are always bundled with their component.
code
ts · 13 linesimport {Injectable} from '@angular/core';
import {bootstrapApplication} from '@angular/platform-browser';
import {App} from './app';
// Tree-shakable: removed from the bundle if no file injects it.
@Injectable({providedIn: 'root'})
export class ReportService {}
// Not tree-shakable: the providers array references the class.
@Injectable()
export class LegacyAuditService {}
bootstrapApplication(App, {providers: [LegacyAuditService]});go deeper
Remember that providedIn root and @Service() let unused services be removed from the production bundle, while providers arrays keep them.
Explain the reversed reference: the class carries its own definition consulted on first injection, while configuration arrays reference the class directly.
Apply it to bundle audits: find services kept alive by stray providers entries and move library defaults to root-provided services and factory tokens.
Set library authoring rules — root-provided services, factory tokens, lightweight tokens — so consuming apps pay only for what they use.
## Tree shaking in one sentence **Tree shaking** is the bundler's dead-code elimination: when building for production, it keeps only modules and exports that are reachable from the application's entry point, following imports and value references. Anything that is referenced — even just from a configuration array — is kept. ## The two directions of reference There are two ways to tell Angular's DI about a service, and they point in opposite directions: | | Providers array | `providedIn: 'root'` / `@Service()` | |---|---|---| | Where the registration lives | In configuration (`ApplicationConfig`, a route, a component, an NgModule) | On the service class itself | | Who references whom | Configuration → service class | Service class → injector scope | | Kept in bundle when nobody injects it | **Yes** | **No** | | Typical place | App-specific wiring, overrides | Default for services, library APIs | ### Providers array ```ts bootstrapApplication(App, {providers: [ReportService]}); ``` `main.ts` now imports `ReportService` and places it in an array. The bundler sees a value reference and keeps the class and everything it imports — even if no component ever calls `inject(ReportService)`. ### `providedIn: 'root'` ```ts @Injectable({providedIn: 'root'}) export class ReportService {} ``` The compiler attaches an **injectable definition** to the class: the token, a factory that creates it, and `providedIn: 'root'`. When code calls `inject(ReportService)`, the root injector finds no explicit record, reads that definition, sees it is scoped to root, and creates the instance on the spot. The only references to `ReportService` are in the files that inject it; if there are none, the class is unreachable and removed. `@Service()` in Angular 22 emits the same kind of definition with `providedIn` set to `root` (unless `autoProvided: false`), so it is tree-shakable for the same reason. ## Where it matters most 1. **Libraries.** A UI library may ship dozens of services. With `providedIn: 'root'`, an application that uses two components pays for their services only. 2. **Large apps with optional features.** A feature's services disappear from the main bundle when the feature is not imported. 3. **Tokens.** An `InjectionToken` created with `providedIn: 'root'` and a `factory` is tree-shakable the same way; a token whose default lives in a `providers` array is not. ## What does not make a provider tree-shakable - Listing a class in **component** `providers`: the component's metadata references it, so it ships with the component. The Angular guide notes such providers are always included in the component's bundle, even if never injected. - Listing a root-provided class **also** in a `providers` array: the array reference keeps it alive and additionally creates a separate instance at that level. - Referencing a class in a **value position** elsewhere — for example a `contentChild(SomeComponent)` query in a library component keeps `SomeComponent` alive even if unused. That is the problem **lightweight injection tokens** solve, a separate design pattern. ## Checking it in a real build - Build for production and inspect the bundle with a source-map explorer: a service you believe unused but still see in `main` is usually referenced from a `providers` array or a value import somewhere. - Search for the class in `providers:` arrays and `importProvidersFrom()` calls; each hit is a reference the bundler must honour. - Remember that **type-only** imports (`import type {ReportService}` or a type annotation) are erased by the compiler and do not keep a class alive; value references do. - Development builds are not optimised the way production builds are, so judge bundle contents only from production output. ## Interview-ready summary - Tree-shakable = the service registers itself; configuration does not mention it. - `providedIn: 'root'` and `@Service()` qualify; providers arrays do not. - The payoff is largest in libraries and optional features; in a small app with every service used, the bundle difference is small, but the default still costs nothing.
- Does listing a providedIn root service in ApplicationConfig.providers as well change anything?Yes. The array now references the class, so it is always bundled, and the root injector uses the explicit record instead of the class's own definition. In `ApplicationConfig` that is still one root instance, but it overrides the default and is no longer tree-shakable. In a route or component array it creates an extra instance at that level.
- Are component-level providers ever tree-shaken?Not independently. The component's metadata references each class in its providers array, so they ship wherever the component ships, even if nothing injects them. The component itself can be dropped if unused, and its providers go with it.
saying these in an interview costs you the question
- Any service not injected anywhere is removed regardless of how it is provided
- providedIn: 'root' services are bundled into main.js eagerly
- Tree-shakable means the service is loaded lazily at runtime
- Component providers are tree-shaken separately from their component
- Adding a root service to ApplicationConfig.providers keeps it tree-shakable