Mert Özen Further With Every Line
Developer Tools Debugging and Problem Solving — #07

Bug Hunting in an Angular App: Signals, Change Detection, RxJS and the HTTP Layer

Mert Özen Nov 8, 2026 11 min 5 views
Bug Hunting in an Angular App: Signals, Change Detection, RxJS and the HTTP Layer

In Angular you narrow a bug by splitting the UI, state, HTTP and API chain down the middle. With real Vitest output: a computed that never updates because an array was mutated in place, a signal read after await that is not tracked, a stale screen and a hidden NG0100 under Angular 22's default OnPush, a search stream killed by catchError, switchMap cancellations and interceptor order.

Version note: The code in this post was built and run with Angular 22.2, RxJS 7.8 and Vitest 5.1; every output comes from real ng test runs.

Guess First

The Bookstore cart badge is fed by a computed signal. A user adds two books: one with quantity 1, the other with quantity 2. The code says items.update(items => { items.push(item); return items; }). What does the badge show: 3, 0, or the 1 from after the first add? Second question: the search box gets an HTTP (Hypertext Transfer Protocol) 500 once, then the user types a new word. Does a new request go out? We will see the answers with real test output; write your guess down first.

Taking the Method to the Frontend: the UI → State → HTTP → API Chain

The loop from the first post of this series still applies: observe, hypothesize, experiment. The difference on the frontend is that a bug can start in any of four layers and they all look the same on screen: "the badge does not update". Is the UI (User Interface) showing the wrong thing, is the state wrong, did the HTTP request never go out, or did the API (Application Programming Interface) return bad data? You apply the binary search idea from post 03 of this series to this chain: split it in the middle first.

The middle question is always the same: did the data arrive over the network? If you can see the request and its response in the Network panel, the problem is not in the API; it is above it. The second question: is the state right? In a development build you can select an element in the Console and type ng.getComponent($0) to reach the component instance, then call its signals to read their values. If the state is right but the screen is wrong, the problem is in change detection; if the state is wrong, it is in the code that changes the state. We cover the panels themselves in the upcoming Browser DevTools series (03 Sources, 04 Network, 09 Angular DevTools); here we focus on method and cases.

One more tip: instead of following everything step by step in the debugger, turn the behavior you suspect into a small Vitest test. The "failing test first" rule from post 02 of this series works on the frontend too, and in Angular writing a test is often faster than clicking around in the browser. Every case in this post was caught with a test like that.

The Signal Changed but the computed Did Not: the Same-Reference Trap

The ticket: "I add to the cart and the badge stays at zero. Refreshing the page fixes it." The store looks like this:

@Injectable({ providedIn: 'root' })
export class CartStore {
  readonly items = signal<CartItem[]>([]);
  readonly count = computed(() => this.items().reduce((sum, i) => sum + i.quantity, 0));

  // BUG: mutates the existing array, returns the same reference
  addBuggy(item: CartItem): void {
    this.items.update(items => {
      items.push(item);
      return items;
    });
  }

  add(item: CartItem): void {
    this.items.update(items => [...items, item]);
  }
}

The spec that tests the hypothesis adds two books and prints both the array length and count. Real output:

stdout | src/app/cart/cart-store.spec.ts > CartStore > addBuggy: count after two adds
count before: 0
items.length: 2 count: 0

FAIL  src/app/cart/cart-store.spec.ts > CartStore > addBuggy: count after two adds
AssertionError: expected +0 to be 3 // Object.is equality

The array has two elements but count is still 0. The cause is the signal's equality check: by default the new value is compared with the old one using Object.is. Because push mutates the same array and the same reference is returned, the signal says "I did not change", and count keeps serving the 0 it cached the first time it was read. That is also why a refresh fixes it: new store, new array.

The fix is the add method: a new array on every change. The same test against add prints items.length: 2 count: 3 and passes. The debugging lesson: asking "is the state right?" by looking at the array was not enough; the array was right. The real question was "was the state change announced?"

What Does an Effect Track? A Signal Read After await

The second case is sneakier. The cart should be synced to the server whenever it changes; the developer added an await that loads customer preferences first:

effect(async () => {
  const id = customerId();
  await Promise.resolve(); // e.g. await this.loadPreferences(id)
  runs.push(`sync customer=${id} cart=${cartCount()}`);
});

The test raises the cart to 1, then to 2, and finally switches the customer from 7 to 8, calling TestBed.tick() after each step. How many times did the effect run?

[ 'sync customer=7 cart=0', 'sync customer=8 cart=2' ]

The cart changed twice and the effect did not fire once. An effect records as dependencies only the signals read during its synchronous run. Code after await runs in a separate microtask; the cartCount() read there is not attached to any effect. The same rule applies to conditional reads: if you write if (loggedIn()) { ... cartCount() ... }, cart changes do not trigger the effect while the user is logged out, because cartCount was not read in the last run.

The fix is to read every signal you want tracked at the top of the function, before the await, and to move the async work into a separate method that reads no signals. The fastest diagnostic experiment is a console.log on the effect's first line to count which changes actually run it.

The Screen Does Not Update: Zoneless and Default OnPush in Angular 22

Since Angular 21, new applications are zoneless, meaning there is no Zone.js. Angular 22 took one more step: components that do not set a strategy are now OnPush. The old Default value was renamed Eager and deprecated; the type definitions in @angular/core 22.2.5 say plainly "OnPush is enabled by default". The practical consequence: a field that changes without telling Angular does not update the screen.

@Component({
  selector: 'app-book-stock',
  template: `<span class="stock">{{ stock ?? 'loading' }}</span>`,
})
export class BookStock implements OnInit {
  stock: number | null = null;

  ngOnInit(): void {
    // a callback Angular is not notified about (e.g. a third-party SDK)
    setTimeout(() => {
      this.stock = 5;
    }, 0);
  }
}

The signal twin of the same component uses stock = signal<number | null>(null) and calls stock.set(5) inside the timer. Output of the test that renders both and waits for the timer:

plain field : loading
signal      : 5

The field's value became 5 but the screen shows "loading". According to the zoneless guide, the things that trigger change detection are specific: updating a signal read in a template, markForCheck (AsyncPipe calls it for you), ComponentRef.setInput, and template or host listeners. A plain field assignment is none of them. The quick diagnostic question: "How does this change notify Angular?" If you cannot answer, you found the bug. Reactive forms have the same trap: setValue and patchValue do not schedule change detection on their own.

One more illusion while debugging: if another signal on screen updates, the same component is checked again and the plain field "accidentally" shows up. In our first attempt we put both values in one component and both showed 5; that is what hid the bug. So separate your variables when you test a hypothesis.

Why Does NG0100 Sometimes Not Show Up at All?

NG0100 (ExpressionChangedAfterItHasBeenCheckedError) is the extra development-mode check, run after change detection, noticing that a binding changed. The classic cause is changing a value used in the template inside ngAfterViewInit:

@Component({
  selector: 'app-order-summary',
  template: `<h2>{{ heading }}</h2>`,
})
export class OrderSummary implements AfterViewInit {
  heading = 'Order';

  ngAfterViewInit(): void {
    this.heading = 'Order #1042';
  }
}

The test that renders this component with fixture.detectChanges() raised no error, and h2 stayed "Order". Wrong screen, no warning. Give the same component changeDetection: ChangeDetectionStrategy.Eager and the error comes back. The type definitions explain why: the check that runs after regular change detection does not work for OnPush components. With OnPush as the default, that check covers less ground.

To surface hidden errors you can use provideCheckNoChangesConfig({ exhaustive: true }) in development (the zoneless guide lists it as developer preview). With that provider added to the test, the real output is:

caught: Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'Order'. Current value: 'Order #1042'. Expression location: _OrderSummary component. Find more at https://v22.angular.dev/errors/NG0100

The message gives you everything: the old value, the new value and the component name. We talked about the habit of reading error messages in post 04 of this series; explanations of NG0xxx error codes live under angular.dev/errors. The fix matches the docs: set the value in ngOnInit or the constructor, or turn it into a signal.

RxJS: Watching the Stream with tap, a Dead Stream and Cancelled Requests

The search box ticket: "After one error, search stops completely; you have to refresh the page." In the RxJS (Reactive Extensions for JavaScript) code, catchError sits on the outer stream:

// BUG: catchError on the outer stream
readonly resultsBuggy: Observable<BookDto[]> = this.terms.pipe(
  switchMap(term => this.http.get<BookDto[]>('/api/books', { params: { q: term } })),
  catchError(() => of([])),
);

A test that answers the first request with a 500 using HttpTestingController and then sends a new word:

seen: [ 'next(0)', 'complete' ] | open requests for dune: 0

The stream emitted one empty result and completed. catchError caught the error, but because the error reached the outer stream, that stream ended; nobody listens to the next words on terms any more. The user sees no error, and the Network panel shows no new request. The cause of the "request never goes out" symptom is not in the HTTP layer but in where the operator sits.

In the fixed version the error is caught inside each request's own pipe, and the unsubscribe callback that tap has had since RxJS 7.3 makes cancellations visible too:

readonly results: Observable<BookDto[]> = this.terms.pipe(
  tap(term => console.log(`[search] term=${term}`)),
  switchMap(term =>
    this.http.get<BookDto[]>('/api/books', { params: { q: term } }).pipe(
      tap({
        next: books => console.log(`[search] ${term} -> ${books.length} books`),
        error: err => console.log(`[search] ${term} -> error ${err.status}`),
        unsubscribe: () => console.log(`[search] ${term} -> cancelled by switchMap`),
      }),
      catchError(() => of([])),
    ),
  ),
);
[search] term=du
[search] term=dun
[search] du -> cancelled by switchMap
first request cancelled: true
[search] dun -> error 500
[search] term=dune
[search] dune -> 1 books
seen: [ 'next(0)', 'next(1)' ]

This log shows two things at once. The "dun" search got a 500 but the stream is alive; "dune" brought results. And the "du" request was cancelled by switchMap the moment "dun" arrived. In the Network panel you see those requests as "(canceled)", and that is not a bug, it is switchMap doing its job. But if you use the same operator for a POST that saves an order, a double click cancels the first request on the browser side; the server may already have received it. For writes, consider concatMap or exhaustMap.

Interceptor Order: Who Sees It First?

Another ticket: "The Authorization header never appears in the logs; is the token not being sent?" The token is sent; the problem is interceptor order. Two interceptors, each logging the request and the response events:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  console.log('auth    -> request');
  const authed = req.clone({ setHeaders: { Authorization: 'Bearer test-token' } });
  return next(authed).pipe(tap(e => console.log(`auth    <- ${HttpEventType[e.type]}`)));
};

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  console.log(`logging -> ${req.method} ${req.url} auth=${req.headers.has('Authorization')}`);
  return next(req).pipe(tap(e => console.log(`logging <- ${HttpEventType[e.type]}`)));
};

Two runs, withInterceptors([loggingInterceptor, authInterceptor]) and the reverse order:

--- logging-first
logging -> GET /api/orders/1042 auth=false
auth    -> request
auth    <- Sent
logging <- Sent
auth    <- Response
logging <- Response
--- auth-first
auth    -> request
logging -> GET /api/orders/1042 auth=true
logging <- Sent
auth    <- Sent
logging <- Response
auth    <- Response

The request goes through in array order, the response in reverse order. When logging comes first, it sees the request before the header is added: the log is not lying, it is just looking too early. The second lesson in the output: tap in an interceptor sees every HttpEvent, not just one response. The first one to fire is the Sent event; an interceptor that measures "response arrived" time or reads the body without checking e.type === HttpEventType.Response runs twice. A retry interceptor is order-sensitive as well: placed before auth, the auth interceptor runs again on every attempt (so the token can be refreshed); placed after, the header from the first attempt is sent again as is.

In Production

A production build has no ng globals and no development checks like NG0100; error codes come with short messages. So frontend diagnosis in production relies mostly on the traces you leave behind. provideBrowserGlobalErrorListeners() routes uncaught errors to Angular's ErrorHandler; your own ErrorHandler should send them to the server along with the request's trace ID. That way, as in post 08 of this series, you can build a timeline from the browser to the API with a single trace ID.

How you ship source maps to production matters too. A public source map exposes your code; no source map at all makes stack traces unreadable. The common solution is to generate source maps and upload them only to your error tracking tool. Do not carry the logs you added with tap into production either; write a single logging operator switched on by a debug flag, and never log user data (email, address, token).

Finally, if "sometimes it does not update" complaints suddenly increase in an older app moving to zoneless and OnPush, keep provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) on in development for a while. It points out, one NG0100 at a time, the bindings that change without notifying Angular; switch it off once the list is done.

Common Pitfalls

Mutating an array or object inside a signal in place. push, splice and item.quantity++ do not announce a change to the signal; always produce a new reference. Reading a signal after await inside an effect breaks just as quietly.

Sprinkling ChangeDetectorRef.detectChanges() around when the screen does not update. It suppresses the symptom and leaves the cause. Turning the value into a signal, or binding the Observable with AsyncPipe or toSignal, is the lasting fix.

Putting catchError on the outer stream and believing "I handled the error", or giving subscribe no error callback and leaving the error to RxJS's global reporting. If you do not know where an error goes, narrow it down by looking after each operator with tap({ error }).

Seeing "(canceled)" in Network and filing a backend bug. Check switchMap and route changes first; the cancellation is often intentional. And last, treating an NG0100 that never shows up in development as "not there": with OnPush as the default, the check covers less.

A Small Experiment

★☆☆ Add increaseQuantity(bookId) to CartStore. Write it first with items.update(items => { items.find(...)!.quantity++; return items; }), watch the failing test show that count does not change, then fix it by producing a new array and a new object.

★★☆ In BookSearch, use mergeMap instead of switchMap and answer the "du", "dun", "dune" requests in reverse order (the flush order). Which result stays on screen? Repeat the test with switchMap and explain the difference from the log.

★★★ Write a setup file that runs every component of an application under provideCheckNoChangesConfig({ exhaustive: true }) in the test environment and run the existing test suite. For every NG0100 that shows up, note which value changed without notifying Angular.

We could reproduce every bug in this post in development, whenever we wanted. In the next post we lose that luxury: the error happens only in production, only for some users, and all we have is logs. We will follow a request across services with correlation IDs and trace IDs and build a timeline.