The Console Panel in Depth: The console API, Live Expressions, Utilities and Angular's ng Globals
The Console panel is not a log window but a REPL that opens into the page: levels and filters, console.time/count/assert/trace, top-level await, Live Expressions and utilities such as $$, getEventListeners and monitorEvents. In Angular 22: ng.getComponent, getContext, getListeners and getDirectiveMetadata, why a plain field needs ng.applyChanges in an OnPush, zoneless world, and the live-reference trap of logged objects, with real outputs from Chrome 156.
Version note: The examples in this post were run with Chrome 156 (156.0.8078.80), Angular 22.2 and .NET 10. Console outputs were captured from the same Chrome version in headless mode through the Chrome DevTools Protocol (CDP), with the Command Line API (Application Programming Interface) that the Console panel uses turned on; panel settings come from the Chrome DevTools documentation.
Predict First
Two small questions. First: you run this in the console:
window.order = { id: 1042, status: 'Created', items: [{ title: 'Compilers Volume 1' }] };
console.log('order', order);
order.status = 'Paid';
order.items.push({ title: 'Patterns Volume 4' });
When you expand the logged object a few seconds later, what does status say, and how many elements does items show? Second: the Cart component on the Bookstore cart page has a plain field called note. If you type ng.getComponent($('app-cart')).note = 'Changed from console' in the console, does the text on the screen change? In Angular 22, components are OnPush by default and the app is zoneless; do those two facts change the answer?
In the previous post we used the node selected in the Elements panel as $0 in the console. In this post we stop treating the Console panel as a log viewer and use it as a REPL (Read-Eval-Print Loop) that opens into the page.
Log Levels, Filters and Preserve Log
The Console has four levels: Verbose, Info, Warning and Error. console.log and console.info are Info, console.warn is Warning, console.error is Error, and console.debug lands in Verbose. The default level selection leaves Verbose off; the answer to "I logged it with debug and it doesn't show" is usually in the Log Levels drop-down. The Console Sidebar on the left (the icon in the toolbar) groups messages by source and level, so you see at a glance which file is flooding the console.
The Filter box accepts three kinds of input. Plain text shows messages containing that text. You can also type a regex such as /cart\s(saved|cleared)/; you have to use \s instead of a space. With the url: prefix you see only messages from a given source, and with -url: everything except that source. If you are tired of the Angular dev server's [vite] connecting... lines, -url:@vite/client does the job.
Under Console Settings (the gear icon), three settings matter in daily work. Preserve log keeps messages when the page reloads or navigates elsewhere; it is the only way to catch an error that disappears in the redirect after login. Selected context only shows messages from the selected JavaScript context when there are several, such as iframes or workers. Log XMLHttpRequests writes every fetch and XHR (XMLHttpRequest) request to the console. To clear the console, use Ctrl+L (Cmd+K on a Mac) or clear().
The Less-Used Methods of the console API
Most teams use the console only through console.log. The methods that speed up debugging go much further. We ran this on the Bookstore list:
console.time('filter');
const hits = $$('app-book-card').filter(c => c.textContent.includes('Compilers'));
console.timeEnd('filter');
console.count('add-to-cart'); console.count('add-to-cart');
console.assert(hits.length === 0, 'expected no Compilers books, got', hits.length);
console.log('%cBookstore%c debug', 'color: #38bdf8; font-weight: 700', 'color: inherit');
console.group('cart'); console.info('items: 0'); console.groupEnd();
function addToCart() { console.trace('who called addToCart?'); }
function onClick() { addToCart(); }
onClick();
console.debug('verbose message');
The messages from CDP's Runtime.consoleAPICalled event, with their types:
[timeEnd] filter: 1.671142578125 ms
[count] add-to-cart: 1
[count] add-to-cart: 2
[assert] expected no Compilers books, got 286
[log] %cBookstore%c debug color: #38bdf8; font-weight: 700 color: inherit
[startGroup] cart
[info] items: 0
[endGroup] console.groupEnd
[trace] who called addToCart? | stack: addToCart:9 <- onClick:10 <- (anonymous):11
[debug] verbose message
Let's read it line by line. time/timeEnd pair up by label and print the elapsed time in milliseconds; searching text across 2000 cards took 1.67 ms. That is a micro-measurement; for real performance questions we will go to the Performance panel. count counts how many times the same label has been called, which is exactly what you want for checking how often an event handler fires. assert only prints when the condition is false; here 286 "Compilers" books were found, so the message appeared, and in the panel it shows at Error level with an "Assertion failed:" prefix. %c applies the CSS (Cascading Style Sheets) in the next argument; CDP delivers the raw arguments and the panel does the styling. group/groupEnd collect messages under a collapsible heading, and groupCollapsed starts it closed. trace prints the call stack at that point: addToCart was called by onClick.
console.table is missing from this list because its output is a table view, but it is one of the biggest time savers. console.table(books.slice(0, 5), ['title', 'price']) shows only the columns you pick, and you can sort by clicking a column header. Try it instead of plain console.log when logging an array of objects.
Running Code in the Console: Eager Evaluation, await and Live Expressions
With the Eager evaluation setting on, the result appears as a gray preview while you type, before you press Enter. DevTools only previews expressions it can be sure have no side effects; a state-changing expression like order.items.push(...) gets no preview.
The console supports top-level await; no async function wrapper needed:
(await (await fetch('http://localhost:5391/api/books/3')).json()).title
// "Networks Volume 3"
The result of the last evaluated expression is kept in $_: after typing 6 * 7 and then $_, we got 42. Right-click a logged object and choose Store as global variable, and it is assigned to a global such as temp1 or temp2 that you can work with in the console.
Live Expressions are for an expression you want to watch continuously. Click the eye icon in the Console toolbar (Create live expression) and type the expression; the result stays pinned above the console and is recomputed every 250 milliseconds. Two good candidates on Bookstore: document.activeElement shows live where keyboard focus is, and $$('app-book-card').length tracks the number of cards in the DOM (Document Object Model) while you type in the filter. Because the expression runs every 250 ms, do not put heavy work in it, or you are the one slowing the page down.
The Console Utilities API: $, $$, monitorEvents, getEventListeners, queryObjects
These are not part of JavaScript; they are helpers that exist only in the DevTools console. You cannot call them from the page's own code. What we ran on Bookstore and the real results:
$('app-book-card .price').textContent
// "$21.00"
$$('app-book-card').length
// 2000
Object.keys(getEventListeners($0)) // $0: the first card's button
// ["click"]
getEventListeners($0).click[0] // only the fields we picked
// {"type":"click","useCapture":false,"passive":false,"once":false}
$() is shorthand for document.querySelector and $$() for querySelectorAll; the difference is that $$ returns a real array, not a NodeList, so filter and map work directly. If the page loads jQuery, $ belongs to jQuery and the DevTools one is overridden. $0 is the node you last selected in Elements, and $1 through $4 are the ones you selected before it.
getEventListeners returns the listeners attached to an element with addEventListener, as an object grouped by event type. The Event Listeners tab in the Elements panel shows the same information, with a "Framework listeners" option as well. monitorEvents logs an element's events to the console. We ran monitorEvents($('.filter'), 'input') for the filter input, typed two letters, and two lines landed in the console:
input InputEvent
input InputEvent
When you are done, unmonitorEvents($('.filter')). You can also pass a group name such as 'key' or 'mouse' as the second argument. copy(value) copies a value to the clipboard; copy(JSON.stringify(temp1, null, 2)) is the shortest way to turn an API response into a test fixture. queryObjects(Constructor) lists every instance created from that constructor on the heap; we will use it in the Memory post when hunting leaks.
Angular's ng Globals
In a development build, Angular adds debugging functions to window.ng. The output of Object.keys(ng) in Angular 22.2:
applyChanges, enableProfiling, getComponent, getContext, getDirectiveMetadata, getDirectives,
getHostElement, getInjector, getListeners, getOwningComponent, getRootComponents, isSignal,
toggleWatchSignal, ɵgetComponentInstanceDeepLinkId, ɵgetControlFlowBlocks,
ɵgetDependenciesFromInjectable, ɵgetInjectorMetadata, ɵgetInjectorProviders,
ɵgetInjectorResolutionPath, ɵgetLoadedRoutes, ɵgetRouterInstance, ɵgetSignalGraph,
ɵgetTransferState, ɵnavigateByUrl, ɵsetProfiler
Only the first eleven functions appear in the "window.ng globals" section of the Angular documentation. The ones starting with ɵ are private API: the Angular DevTools extension uses them, you should not use them in your code, and they can change between versions. With the card's button as $0, the results were:
ng.getListeners($0)
// [{"name":"click","type":"dom","useCapture":false}]
ng.getListeners($0.closest('app-book-card'))
// [{"name":"addToCart","type":"dom"},{"name":"addToCart","type":"output"}]
ng.getContext($0.closest('app-book-card')) // keys, $index and the book's title
// {"keys":["lContainer","$implicit","$index"],"index":0,"title":"Compilers Volume 1"}
ng.getDirectiveMetadata(ng.getComponent($0.closest('app-book-card')))
// {"inputs":{"book":"book"},"outputs":{"addToCart":"addToCart"},"encapsulation":0,"changeDetection":0}
Unlike getEventListeners, getListeners shows the listeners Angular bound in the template and tells you their type: dom is a DOM event, output is a component output. That is also why addToCart appears twice on the host element: for the (addToCart) binding in BookList's template, Angular registered both a DOM listener with the same name on the host element and a subscription to the output; the emit call arrives through the output. getContext gives the loop context for an element inside an @for: $implicit is that row's book and $index its position. It is the shortest answer to "which data was this row rendered with".
Look at the two numbers in getDirectiveMetadata. encapsulation: 0 is ViewEncapsulation.Emulated, and changeDetection: 0 is ChangeDetectionStrategy.OnPush. We never wrote changeDetection in BookCard; Angular 22 defaults to OnPush, so we got 0. In Angular 22's source, the old Default value has given way to Eager; both are 1, and Default is deprecated.
getInjector returns an element's injector; ng.getInjector(card).get(CartStoreClass) gets you the service instance the component sees. In our test, the CartStore taken from the card was the same instance as the one in BookList (sameStore: true): the single instance provided in root. getRootComponents showed something unexpected: it returned ["_App"] for app-root and a nav link, but ["_BookList"] for an element inside the BookList loaded by the router. The view of a router-created component behaves like its own root view; if by "root component" you expect the bootstrapped one, know about this result.
applyChanges: Changing Things from the Console in an OnPush, Zoneless World
Back to the second question from Predict First. The Cart component:
@Component({
selector: 'app-cart',
imports: [CurrencyPipe],
template: `
<h2>Cart</h2>
<p class="summary">{{ cart.items().length }} items, total {{ cart.total() | currency: 'USD' }}</p>
<p class="note">{{ note }}</p>
`,
})
export class Cart {
protected readonly cart = inject(CartStore);
protected note = 'Free shipping over $50';
}
We changed the field from the console, waited 300 ms, then called ng.applyChanges:
$('.note').textContent
// "Free shipping over $50"
ng.getComponent($('app-cart')).note = 'Changed from console'
$('.note').textContent // 300 ms later
// "Free shipping over $50"
ng.applyChanges(ng.getComponent($('app-cart')))
$('.note').textContent
// "Changed from console"
The screen did not change. In the old Zone.js world with the Default strategy, this assignment would have reached the screen on the next change detection pass (for example, on the next click on the page); in a zoneless app nothing tells Angular "something changed", and because the component is OnPush it is not checked unless it is marked dirty anyway. As the documentation says, applyChanges marks the component for check and runs change detection on the application synchronously. Signals are different: when we typed ng.getComponent($('app-book-list')).query.set('Compilers'), the counter became "286 books" without calling applyChanges, because writing to a signal schedules a render by itself. The mechanics of Zone.js and OnPush are in the Angular series posts "Change Detection and Zone.js in Angular: OnPush, ChangeDetectorRef and NgZone" and "Angular 22: OnPush by Default, Stable Signal Forms and a File-by-File Comparison with Angular 12".
The last important member of the list is ng.enableProfiling(): it opens an Angular-specific track in the Performance panel. We will use it in post 06 while recording a trace.
A Logged Object Is a Live Reference
Now the first question. We logged the same object twice, once directly and once copied with structuredClone, then changed the object. The preview taken at log time versus the values that come back when the object is expanded later (Runtime.getProperties):
[order] preview at log time: {id: 1042, status: Created, items: Array(1)} | expanded now: status=Paid, items=Array(2)
[order snapshot] preview at log time: {id: 1042, status: Created, items: Array(1)} | expanded now: status=Created, items=Array(1)
The collapsed line in the console shows the preview from log time: status: Created. But the moment you click the arrow to expand it, DevTools asks for the object's current properties, and you see Paid and a two-element array. The panel also marks this with a small "i" icon next to the expanded object: the value was evaluated just now. The problem usually plays out like this: you write console.log(items) inside an effect, expand the line, see the latest state, conclude "the log already shows the right value, so the bug must be elsewhere", and head in the wrong direction.
The second line is a deep copy taken with structuredClone; even when expanded it keeps its state from log time: status=Created, a one-element array. If you want the value at log time, either log a copy or write a primitive value such as console.log('status', order.status). JSON.stringify(order) works too, but it mangles types such as Map, Set and Date.
Production Notes
console.log calls are not removed from a production build on their own. In Bookstore's ng build output, console.log("cart saved",n.length) was sitting in main-*.js as is. Remember that you are writing orders, tokens or personal data to a console that is open in the user's browser; in production, route logs through a logging service that can be switched off by level.
While DevTools is open, objects you log to the console are held by the panel as references, and the garbage collector cannot collect them. When measuring memory, clear the console and remove unnecessary logs, or your own log will produce the leak.
The ng globals do not exist in a production build; if you need to look at a component's state in a deployed environment, use the Sources panel with source maps and breakpoints. The Console Utilities also only work in the console; you can use $$ or copy in a Snippet, but put them in application code and you get a ReferenceError.
Common Pitfalls
- Taking the value you see when expanding a log for the value at log time; remember the two lines above.
- Writing
console.debugand never seeing the message because the Verbose level is off. - Searching for an error that vanishes after a redirect or reload; turn on Preserve log.
- Changing a plain field from the console and expecting the screen to update; in Angular 22 you need
ng.applyChanges, or keep the state in a signal. - Writing scripts that rely on
ngfunctions with theɵprefix; they can break with any Angular version. - Putting a heavy expression in a Live Expression and then wondering why the page got slow.
A Small Experiment
★☆☆ On the Bookstore list, run console.table($$('app-book-card').slice(0, 5).map(c => ng.getComponent(c).book()), ['title', 'price']) and sort the columns. Then create a Live Expression for $$('app-book-card').length and watch the value change while you type in the filter.
★★☆ To see how many times the add-to-cart button fires, put a temporary console.count('add-to-cart') in the handler in BookList. In the console, find the button's listener with ng.getListeners and that row's book with ng.getContext, and compare the counter after reloading the page with Preserve log on and off.
★★★ Log the cart array inside an effect, then add a code path that mutates the array in place. Compare the preview in the console with the expanded view, and fix it with structuredClone. Then update the same data by giving the signal a new array reference, and confirm the screen updates without ng.applyChanges.
Seeing "what" something is in the console is easy; seeing "why and when" requires pausing the code. In the last exercise you will notice that the cart sometimes does not update; in the next post we use the Sources panel, its breakpoint types, source maps and the ignore list to find, step by step, exactly why that effect does not fire.