Angular DevTools: Component Tree, Profiler, Injector Tree and Router Tree
Angular DevTools only works on development builds, and a build made with optimization: false also behaves in development mode: the Service Worker turns off and budgets are exceeded. We read Components, the Signal Graph, the Profiler, the Injector Tree and the Router Tree with real data: fixing NG0201 with a route-level provider and seeing the new Route: reviews injector, and card template updates dropping from 855 to 0 with OnPush and track book.id.
Version note: The examples in this post were run with Chrome 156 (156.0.8078.80), Angular 22.2 and .NET 10. The Angular DevTools extension was not installed or run in this environment (headless Chrome); the tabs' UI and steps come from the Angular DevTools documentation on angular.dev. The data shown was captured from the Angular debug APIs (window.ng) that the extension also reads, and from Performance traces, with the same Chrome version through the Chrome DevTools Protocol (CDP).
Predict First
We add a page for book reviews to Bookstore. The service is written without providedIn:
@Injectable()
export class ReviewService {
readonly reviews = signal(['Clear and practical', 'Too long']);
}
@Component({
selector: 'app-reviews-page',
template: `
<h2>Reviews</h2>
@for (r of reviews.reviews(); track r) {
<p class="review">{{ r }}</p>
}
`,
})
export class ReviewsPage {
protected readonly reviews = inject(ReviewService);
}
Going to /reviews leaves the page empty. Which error shows up in the console, and which injector does the message point to? Where do you provide the service so it belongs only to this page? And one more: a teammate wants to inspect this error on the staging environment with Angular DevTools; does the extension work on a production build?
Installation and the One Requirement: A Development Build
Angular DevTools is an extension installed from the Chrome Web Store and Firefox Add-ons. After installing it, open DevTools and an "Angular" tab appears among the panel tabs. Extensions do not run on Chrome's new tab page, so open the app's page first. The documentation's explicit requirement: Angular DevTools only supports development builds. ng serve provides that by default; on a production build you see "Angular application not detected" or a production build warning.
To inspect a deployed environment, the documentation suggests building with optimization: false. We put that in the production configuration, tried it, and ran into two surprises:
✘ [ERROR] bundle initial exceeded maximum budget. Budget 1.00 MB was not met by 500.21 kB with a total of 1.50 MB.
The unoptimized bundle exceeded the production budgets and the build stopped. We defined a separate configuration:
"debug": { "optimization": false, "sourceMap": true, "outputHashing": "all" }
Opening the ng build -c debug output from a static server:
typeof ng: object | SW controller: false | registrations: 0
log: Angular is running in development mode.
error: Access to fetch at 'http://localhost:5391/api/books' from origin 'http://localhost:5392' has been blocked by CORS policy
The second surprise: a build with optimization off runs in development mode. The ng global exists and Angular DevTools can connect; but everything tied to isDevMode() switched to development behavior too: because of provideServiceWorker(..., { enabled: !isDevMode() }) the Service Worker did not register, and our code that picks the API address based on isDevMode() went to the dev API on localhost. So "inspecting production with Angular DevTools" really means inspecting a build that behaves differently from production. Separating app behavior with environment files instead of isDevMode() narrows that gap.
Components: The Tree, Properties and $ng0
The Components tab holds the app's component and directive tree; filter it by name with the search box at the top. Click an item and its inputs, outputs and other properties appear on the right; double-click to select the host element in the Elements panel. The inspect icon at the top left lets you select the component of the element under the pointer on the page. Change an editable value, press Enter, and the app updates with that value. The icon at the top right of the properties section opens the component's definition in the Sources panel.
The selected component is available in the console as $ng0, previous selections as $ng1, $ng2. It is the Angular counterpart of $0 in Elements; but $0 is a DOM (Document Object Model) element, while $ng0 is a component instance. The tab shows visually the same information we got with ng.getComponent and ng.getDirectiveMetadata in post 02 of this series (the input/output list, changeDetection: 0, which is OnPush).
@defer blocks appear in the tree too: select one and its @loading, @placeholder and @error blocks, triggers (defer, prefetch, hydrate) and minimum/after timings are listed. In apps using SSR (Server-Side Rendering) and hydration, the tree shows each component's hydration status, errors appear as messages on the affected component, and an overlay showing hydration status can be turned on over the page. The experimental Transfer State tab is for inspecting the state carried from server to client in SSR; experimental tabs are turned on from Angular DevTools' own settings.
Signal Graph: Which Signal Feeds What?
Select a component in the Components tab and click Show Signal Graph in the property section's header, and the component's reactive graph opens. The documented requirement is Angular 19+ in development mode. We pulled the data the extension reads directly for BookList (ng.ɵgetSignalGraph, a private API; do not use it in code):
signal graph of BookList → {"nodes":["0 template:app-book-list","1 signal:query=\"\"","2 computed:visible=[{\"id\":1,\"title\":\"Compilers Vo","3 computed:index=[{\"book\":{\"id\":1,\"title\":\"Comp","4 computed:toSignal.source=[{\"id\":1,\"title\":\"Compilers Vo","5 signal:toSignal.state={\"kind\":1,\"value\":[{\"id\":1,\"ti"],"edges":["1->0","2->0","1->2","3->2","4->3","5->4"]}
Read the graph: the template (0) reads two things, query (1) and visible (2). visible depends on both query and index (3), and index depends on the HTTP (HyperText Transfer Protocol) response through the two nodes toSignal creates. When you type in the filter, only the 1 → 2 → 0 path is recomputed; index stays cached. Why the fix in post 06 works is visible at a glance in this graph. We never wrote a debugName in the code; in the development build the nodes were labeled with their property names (query, visible, index). Giving a debugName to nodes that are not assigned to a property, such as effects, makes the graph readable.
Click a node to get its current value and options to jump to its source, set a breakpoint and watch its updates in the console. The last one is the UI counterpart of toggleWatchSignal, which we saw in the Object.keys(ng) output in post 02.
Profiler: Which Component Ran, and Why?
In the Profiler tab, press the circle at the top left to start recording, interact with the app, and press it again to stop. Each bar is a change detection cycle; its height is the time spent in that cycle. Select a bar to see the components in that cycle, the total time and, if it dropped below 60 fps (frames per second), the estimated frame rate; select a component to see the methods called and their durations. In the flame graph view, the x-axis is the cycle's total time and the y-axis is the element hierarchy. The "Show only change detection" checkbox shows only the components that went through change detection; those that did not run, such as OnPush components not marked dirty, appear gray. You can save recordings as JSON (JavaScript Object Notation) and open them on another machine.
In Angular 22, components are OnPush by default; the expectation is that typing in the filter re-renders only the cards whose input changed. We measured this for the two lists with the Angular track of the Performance panel. The measurement was taken on a dev build, with the CPU (Central Processing Unit) slowed down 4x, while typing "Comp", over four change detection passes:
books-slow {'_BookCard': (2284, 241.9), 'BookCard_Template (update)': (855, 98.8), 'RouterLink:ngOnDestroy': (1714, 45.2), 'HostBindings': (867, 46.2)} CD passes 4
books {'_BookCard': (1429, 34.2), 'BookCard_Template (update)': (0, 0.0), 'RouterLink:ngOnDestroy': (1714, 44.2), 'HostBindings': (12, 1.2)} CD passes 4
The numbers in parentheses are the entry count and total milliseconds. In the list with track $index, the templates of 855 cards were updated: as the filter narrowed, rows were matched by position, so every row's book input changed and OnPush did not stop them. In the list with track book.id, card template updates are zero: Angular visited a card 1429 times but skipped the OnPush cards whose input did not change. In the Profiler's flame graph with "Show only change detection" on, these cards appear gray. The 1714 RouterLink:ngOnDestroy entries are the same in both lists: the cleanup of removed cards, independent of the track strategy.
Injector Tree: NullInjectorError and Provider Scope
The real error from the page in Predict First:
ERROR ɵNotFound: NG0201: No provider found for `_ReviewService`. Source: Environment Injector. Find more at https://v22.angular.dev/errors/NG0201
The error belongs to the NullInjectorError family: no provider anywhere in the chain, and the lookup last ended at the Environment Injector. The Injector Tree tab (Angular 17+) shows two trees: the environment hierarchy and the element hierarchy. Select an injector and the resolution path Angular follows from that point to the root is highlighted; for element injectors, the path also includes the environment injectors the lookup jumps to when the element hierarchy has no match. Select an injector with providers and its tokens and types are listed on the right, each loggable to the console in one click.
We took the path the extension shows for a book card ourselves:
resolution path from first app-book-card → ["NodeInjector","NodeInjector","NodeInjector","NodeInjector","R3Injector [Environment Injector]","R3Injector","NullInjector"]
providers in root environment injector (first 12 tokens) → {"count":80,"tokens":["ChangeDetectionScheduler","NgZone","InjectionToken Zoneless enabled","InjectionToken Set Injector scope.","ErrorHandler","InjectionToken EventManagerPlugins","InjectionToken EventManagerPlugins","DomRendererFactory2","InjectionToken SHARED_STYLES_HOST","SharedStylesHost","EventManager","RendererFactory2"]}
The path first goes through the element injectors (the card itself, BookList, App…), then reaches the app's root environment injector, the platform injector above it, and finally NullInjector. The root has 80 providers, most of them the framework's own. To make ReviewService belong only to this page, we provided it at the route level:
{ path: 'reviews', component: ReviewsPage, providers: [ReviewService] },
--- /reviews (fixed) h2: Reviews
resolution path from app-reviews-page → ["NodeInjector","NodeInjector","NodeInjector","R3Injector [Route: reviews]","R3Injector [Environment Injector]","R3Injector","NullInjector"]
There is a new link in the path: Route: reviews. The providers given to a route create a separate environment injector for that route and its children; the service is not visible from outside that route subtree. Seeing this link in the Injector Tree saves you from guessing the answer to "why is the service created twice" or "why can it not be found". The theory of the injector hierarchy is in the Angular series post "Angular Services and Dependency Injection: providedIn, the Injector Tree and Providers".
Router Tree
The Router Tree tab draws the app's route tree with nesting; active routes are highlighted in green. Select a route and the side panel shows its path or matcher, component or redirect target, path match strategy, data, resolvers, providers, canActivate/canActivateChild/canDeactivate/canMatch guards, title, and active/auxiliary/lazy status. The Navigate icon next to the path takes the app to that address. A summary of the router config the extension reads, in our app:
router config → ["'' -> books","books","books-slow","books/:id","cart","reviews (providers: 1)","admin (lazy)"]
For questions like "why is this guard not running" or "why is this lazy route not loading", first check here whether the route is really defined where you expect and with the guard you expect; in large apps where route config is spread across files, this is the tab that saves the most time.
Using It Together with the Performance Panel
Angular DevTools' Profiler shows things from Angular's own point of view, while the Angular track of the Performance panel shows them side by side with all of the browser's work. In practice the order is: record a trace with the Angular track on in Performance, find which component is inside the long task (post 06), then look in the Profiler at which cycle that component ran in and why. According to the Angular documentation, the link in the Summary of a component entry in the Performance panel opens it directly in the Components tab of Angular DevTools; this requires the extension to be installed and the experimental chrome://flags/#enable-devtools-deep-link-via-extensibility-api flag to be on.
Production Notes
A build deployed with optimization: false is both large (1.50 MB initial for us) and runs in development mode; do not put it in an environment open to users. Use a debug build only in a short-lived, access-restricted environment, and remember that its behavior can differ from production (Service Worker, environment selection).
Angular DevTools shows the app's whole state, the data in services and the route config. When working with the extension in an environment holding personal data, be careful with screen sharing and with the JSON files you export from the Profiler.
Common Pitfalls
- Seeing "Angular application not detected" on a production build and assuming the extension is broken.
- Putting
optimization: falseinto the production configuration and hitting a budget error; define a separate configuration. - Taking a debug build that runs in development mode for an exact copy of production behavior.
- Putting a service into a component's
providersand being surprised that each instance creates a new service; check in the Injector Tree where it is provided. - Mixing up
$0and$ng0: one is an element, the other a component instance. - Expecting the list to be fast just because OnPush is the default; if the
trackstrategy changes the cards' inputs, OnPush cannot stop them.
A Small Experiment
★☆☆ Install Angular DevTools, open Bookstore with ng serve and select a BookCard in the Components tab. Read the book with $ng0.book() in the console, then select BookList, open the Signal Graph and watch which nodes change as you type in the filter.
★★☆ Move ReviewService first to the providers of ReviewsPage, then to the route, then to providedIn: 'root'. Each time, note in the Injector Tree which injector the service appears in, and whether a new instance is created when you leave and re-enter /reviews.
★★★ Take separate Profiler recordings for /books-slow and /books, and compare the gray and colored BookCards in the flame graphs with "Show only change detection" on. Save the recordings as JSON with Save Profile and match the difference you find with the Angular track numbers above.
Up to this post we used every tool by hand. In the next and final post the job changes: we cover Gemini AI (Artificial Intelligence) assistance inside DevTools, the Chrome DevTools MCP (Model Context Protocol) server that opens all these tools to coding agents, and the Recorder that records and replays user flows, together with their privacy and security limits.