Why Error Handling Matters

Handling errors is a crucial topic that developers often avoid or neglect. While most of us enjoy building engaging features and polished interfaces, nobody relishes writing code whose sole purpose is to protect the application when things go wrong.

Yet, a key milestone in any developer's growth is accepting that errors are unavoidable. A dependency might contain a defect; a network call could fail; the user's environment might behave unexpectedly. In all these situations—and many others—we must respond gracefully rather than letting trivial, predictable issues crash our application. Let's begin with the fundamentals.

Dealing with Synchronous Errors

Synchronous errors are uncommon in Angular applications, as the vast majority of issues occur during network operations. Still, they can surface, and here are a few typical scenarios:

  1. Calling a third-party library that throws under specific conditions, such as when given malformed input
  2. Using a third-party library that contains a defect triggering an exception
  3. Executing code from your own project that intentionally raises errors
  4. Running your own code that contains a mistake

Addressing these situations is straightforward:

  1. Identify the conditions that trigger the exception and correct your usage of the library
  2. While you cannot modify the faulty library code, you can notify the maintainer and wrap your calls in a try/catch block to manage the failure gracefully. Alternatively, if you have the time, consider fixing the bug and submitting a pull request
  3. Use your own code correctly to avoid triggering errors; if the error is raised improperly, fix the underlying logic
  4. Resolve the bug directly if possible, or find a workaround that avoids the error altogether. Adding try/catch should be a last resort, since it can mask the real problem and introduce complexity. If you do end up using try/catch, add a comment explaining why

So far we've looked at "local" errors—those that arise in specific situations and are managed individually. But what happens when we need to catch errors globally and respond consistently across the entire application? Let's explore that next.

Global Error Handling

Angular offers a mechanism for catching errors application-wide through the ErrorHandler class. This class exposes a single method, handleError, which gets invoked whenever an uncaught error occurs. To leverage it, create a custom class that implements ErrorHandler, override the handleError method, and then register your implementation in the application configuration:

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  // this method will receive the error from anywhere in our app and handle it 
  handleError(error: unknown): void {
    // log the error
    console.error('An error occurred:', error);

    // perform other error-handling operations, like showing toast messages, 
    // sending the error to analytics, and so on

  }
}

Then, in your app.config.ts:

export const config = {
  providers: [
    { provide: ErrorHandler, useClass: GlobalErrorHandler },
  ],
};

That's all there is to it. Now every unhandled error in the application flows through the handleError method of your customized class. You can verify this quickly by introducing an error in any component:

@Component({
  selector: 'app-root',
  template: `
    <h1>Angular Error Handling</h1>
    <button (click)="throwError()">Throw Error</button>
  `,
})
export class AppComponent {
  throwError() {
    // test with the click of the button
    throw new Error('This is a test error!');
  }
}

After clicking the button, the error appears in the console, and any additional logic added to the handleError method executes as expected.

What's particularly useful about ErrorHandler is that it isn't limited to synchronous errors—asynchronous failures also pass through it. To test this, add a simple fetch call to a component:

@Component({
  selector: 'app-root',
  template: `
    <h1>Angular Error Handling</h1>
    <button (click)="fetchData()">Fetch Data</button>
  `,
})
export class AppComponent {
  fetchData() {
    // test with the click of the button
    fetch('http://some-wrong-api.com/wrong-endpoint');
  }
}

Clicking the button triggers the handleError method once again. However, while this works for generic, application-wide tasks, asynchronous errors often require more targeted handling—such as displaying fallback data or navigating to a specific page—so global interception alone isn't always sufficient.

Note: The ErrorHandler gets triggered for any error in your application, including minor ones like a failed image load or an inconsequential exception from a library. Therefore, it's essential to filter error types within the handleError method!

Everything discussed so far concerns errors that occur inside the Angular application. But what about errors originating externally? For instance, a third-party script loaded from the index.html file, or errors raised when using Angular Elements to embed components in other applications. Prior to v20, capturing these wasn't possible, but in v20 Angular introduces a new configuration option. You can enable it by adding this setting to your app.config.ts:

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
  ]
};

This configuration routes errors from the browser's window.onerror and window.onunhandledrejection events into Angular's ErrorHandler, which we've already covered.

With that foundation, let's examine the various strategies for handling asynchronous errors in Angular.

HTTP Errors

Let's begin with HTTP without introducing signals or resources—those come later in the article.

Handling HTTP Errors with RxJS

In an Angular application, an HTTP request made via HttpClient is an Observable, so error handling falls under RxJS. Consider the simplest example:

@Component({
  selector: 'app-root',
  template: `
    <h1>Angular Error Handling</h1>
    <button (click)="fetchData()">Fetch Data</button>
  `,
})
export class AppComponent {
  constructor(private http: HttpClient) {}

  fetchData() {
    this.http.get('http://some-wrong-api.com/wrong-endpoint').subscribe({
      next: (data) => console.log(data),
      error: (error) => console.error('Error fetching data:', error),
    });
  }
}

Here we use only the subscribe method to manage the error. The subscribe method accepts not just a single callback but an Observer object, which may include three callback methods: next, error, and complete.

  • next: invoked when the Observable emits a new value—in this case, the HTTP response
  • error: invoked when the Observable encounters a failure—here, when the HTTP request fails
  • complete: invoked when the Observable finishes; for HTTP, this corresponds to success, so it's often unnecessary here

That said, this is likely the least efficient approach to HTTP error handling in Angular, since there are far better ways to work with Observables. For instance, if you're displaying HTTP data in the template with the async pipe, you can't rely on a subscribe callback—you'd instead show something relevant in the UI.

The catchError operator is the right tool for this. When an error occurs, it calls the function you provide, which must return a new Observable that takes the place of the original. This replacement can be an "error object" that the UI can interpret. Here's the same example using catchError:

import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    <h1>Angular Error Handling</h1>
    <button (click)="fetchData()">Fetch Data</button>
    @if (data$ | async; as data) {
      @if (data.error) {
        <p>Error: {{ data.error }}</p>
      } else {
        <p>Data: {{ data | json }}</p>
      }
    }
  `,
})
export class AppComponent {
  private readonly http = inject(HttpClient);
  error: string | null = null;
  data$: Observable<Data | {error: string}>

  fetchData() {
    this.data$ = this.http
      .get('http://some-wrong-api.com/wrong-endpoint')
      .pipe(
        catchError((error) => {
          // notice the usage of the `of` function, 
          // as `catchError` callback *must* return another Observable
          return of({error}); 
        })
      );
  }
}

As you can see, this is even more concise and straightforward. You're not manually subscribing; you know the result is either a SomeData instance or an error object, and the template can conditionally display the correct UI.

If your application leans heavily on RxJS—as many Angular apps do—you might want to streamline error handling further. One way is through custom RxJS operators. For more on managing error and loading states with custom RxJS, refer to this article by Eduard Krivanek, particularly section 7.

We've now covered RxJS-based HTTP error handling, which is the most granular approach. However, many HTTP failures require a more uniform response—like showing a toast notification or, for low-priority requests, merely logging diagnostics without bothering the user. Let's see how to implement that.

Handling HTTP Errors with Interceptors

If you were thinking, "interceptors would be perfect for this," you're absolutely correct. If interceptors are new to you, check out one of my earlier articles for a detailed explanation.

Using interceptors, you can easily capture errors and apply shared handling logic:

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const toastService = inject(ToastService);
  const diagnostics = inject(DiagnosticsService);

  return next(req).pipe(
    catchError((res) => {
      if (res.type === HttpEventType.Response && res instanceof HttpErrorResponse) {
        toastService.showError('An error occurred while fetching data!');
        diagnostics.logError(res);
        return of(res);
      }
    })
  );
};

Notice that we can use the catchError operator to intercept the error and execute common tasks, like showing a toast or logging to a diagnostics service. Finally, we return the same response so the local error handling—such as updating the UI—can still happen at the component level.

But this pattern can be extended further. Consider a scenario where you want to always log diagnostics but only show a toast for roughly 80% of requests. You'd need to differentiate which requests should trigger the toast. Your first thought might be adding a custom header or query parameter, but Angular provides a built-in mechanism for communicating with interceptors from services: the HttpContext.

This context object can be attached to a request and carry any data you need. It's also accessible inside the interceptor. Let's see how:

// creating the token with data to pass with the request
export const NoToastMessage = new HttpContextToken<boolean>(() => true);

@Injectable()
export class SomeService {
  private readonly http = inject(HttpClient);

  getData() {
    return this.http.get('some.url', {context: NoToastMessage});
  }
}

Now, we can update our interceptor accordingly:

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const toastService = inject(ToastService);
  const diagnostics = inject(DiagnosticsService);

  return next(req).pipe(
    catchError((res) => {
      if (res.type === HttpEventType.Response && res instanceof HttpErrorResponse) {
        // log the diagnostics anyway
        diagnostics.logError(res);
        if (!req.context.get(NoToastMessage)) {
            // if there is not token, show the toast
            toastService.showError('An error occurred while fetching data!');
        }
        return of(res);
      }
    })
  );
};

This gives your interceptor finer control over how to handle each request, preventing unwanted toast messages.

Finally, interceptors also help address a common pain point: custom API error responses. Some APIs don't use standard HTTP status codes like 404 or 500; instead, they return a custom error structure—perhaps a 200 OK with a body like {success: false, error: 'Some error'}.

This isn't inherently problematic, and whether such APIs are a good idea is beyond this article's scope. However, it introduces boilerplate, since you must check for network failures (which can still occur) and then add an if statement to verify the response status.

Interceptors let you eliminate this duplication by performing the check once and raising an error that gets handled downstream:

export const nestedErrorInterceptor: HttpInterceptorFn = (req, next) => {
    return next(req).pipe(
        map(res => {
            if (res.type === HttpEventType.Response) {
                const body = res.body as {success: boolean, message?: string};
                if (body.success === false) {
                    throw new HttpErrorResponse({error: body.message});
                }
                return res;
            }
            return res;
        }),
    );
}

This significantly reduces friction when working with HTTP responses and the diverse range of APIs encountered in frontend development.

Having covered these more established topics, we'll now return to synchronous error handling as we dive into the most exciting addition to Angular 16+—signals.

Signal-Based Error Scenarios

Before we dive deeper, let's clarify what we actually mean when we talk about errors in the context of signals. At their core, signals are just synchronous wrappers around values, so the idea of a signal throwing an error might seem a bit odd at first — much like suggesting a variable could throw an error.

Indeed, standard signals built with the signal function cannot throw errors. However, things change when we look at computed signals. These depend on callback functions that track other signals and run to produce the computed signal's value. Consequently, if an exception occurs inside that callback, it gets thrown and can be caught. Consider the following snippet:

@Component({/* */})
export class SomeComponent {
  count = signal(0);
  doubleCount = computed(() => {
    if (this.count() < 0) {
      throw new Error('Count cannot be negative!');
    }
    return this.count() * 2;
  });
}

If we assign a negative number to count, the computed signal will throw an error. But the question here is: when exactly does that error occur?

To answer this, we need to revisit the underlying mechanics of computed signals. They are lazy by design — Angular strives to defer the execution of the callback (which could be expensive) as much as possible. For instance, the callback does not run when the computed signal is initially defined; instead, its first execution happens when the signal is first read, that is, when we invoke it like this.doubleCount().

At that initial evaluation, Angular records all the signals it observes inside the callback (in our case, count) and keeps track of them so it can recompute the signal's value whenever one of those dependencies changes.

However, this lazy behavior carries over to updates as well. When a tracked signal's value changes, the callback isn't rerun right away — the computed signal is just flagged as "dirty" and execution proceeds. The next time that flagged signal is read, Angular recognizes the potential change and executes the callback again to refresh the value.

And it's at this point that an error surfaces. So, to put it succinctly: signal errors won't trigger randomly when we update some unrelated signal; they occur precisely when a computed signal is read.

This characteristic provides us with some viable strategies for managing these errors.

  • If a computed signal fails due to an edge case or a flawed API usage, it's often simpler to address the root cause rather than wrapping it in error-handling logic.
  • If we have ownership of the computed signal (i.e., it doesn't come from a third-party library), we can wrap its reads in a try/catch block to gracefully manage the exception. It's not the most elegant solution, but it's the only way to catch it directly.
  • If the signal is ours and we foresee certain failure conditions, the best practice is to handle them internally in the callback and provide a fallback value.
@Component({/* */})
export class SomeComponent {
  // we are using some library we cannot change
  private readonly utilities = inject(UtilitiesService);
  count = signal(0);
  newCount = computed(() => {
    try {
      // we might expect an error coming from the library 
      const newValue = this.utilities.calculateNewValue(this.count());
      return newValue;
    } catch {
      // if the library throws an error, we can return some default
      return 0; // or some other default value
    }
  });
}

This last approach is far superior to sprinkling try/catch blocks throughout the codebase. It's also worth noting that computed signals are frequently consumed directly within templates, and Angular's template syntax offers no equivalent to JavaScript's try/catch. Thus, handling errors that occur inside a computed signal used by the UI would be quite challenging without this pattern.

Keep in mind, however, that this technique is best suited for supplying a default value when something fails. If we need to trigger side-effects in response to an error (like showing a toast notification, as discussed in a previous section), it's better to abandon the computed signal altogether and opt for an effect to oversee such scenarios and their subsequent actions.

@Component({/* */})
export class SomeComponent {
  private readonly utilities = inject(UtilitiesService);
  count = signal(0);
  newCount = signal(0);

  constructor() {
    effect(() => {
      try {
        const newValue = this.utilities.calculateNewValue(this.newCount());
      } catch {
        // show a toast message
      }
    });
  }
}

We can take this concept a step further. If the goal is to fall back to the previous value in case of an error, we can swap the computed function for the newer linkedSignal utility. With linkedSignal, we have direct access to the previous state, allowing us to preserve it and essentially "absorb" the error:

@Component({/* */})
export class SomeComponent {
  private readonly utilities = inject(UtilitiesService);
  value = signal(0);
  computedValue = linkedSignal<number, number>({
     source: this.value,
     computation: (previous, current) => {
        try {
          const newValue = this.utilities.calculateNewValue(current.source);
        } catch {
          return previous;
        }
     }
   }).asReadonly();
}

In this example, we take another signal, apply a computation to it, and if successful we receive the new value. If an exception is thrown, we fall back to the previous value provided by linkedSignal and return it, keeping the consumer completely unaware that anything went wrong. Additionally, we apply the asReadonly method to ensure the signal behaves like a simple computed property and remains unwritable.

Note: This pattern is highly dependent on the specific requirements at hand, and in many situations it is more prudent to expose the error rather than silently suppress it, opening the door for other error-handling strategies.

Moving on, another way an error can enter our signal pipeline is when we create a signal from an Observable using the toSignal function. In this particular case, traditional try/catch is not viable. Instead, we must revisit the earlier part of this discussion and delegate error handling to the RxJS pipeline with the catchError operator. We can apply it exactly as demonstrated before, returning an "error object" that can be examined later when we read the signal, whether in the template or in the component logic.

We can also combine this approach with linkedSignal as seen earlier, so that in the event of an error, the observable-based signal retains its most recent valid value:

@Component({/* */})
export class SomeComponent {
  private readonly http = inject(HttpClient);
  rawData = toSignal(this.http.get('http://some-wrong-api.com/wrong-endpoint').pipe(
    catchError((error) => {
      // return an error object
      return of({error});
    })
  ));
  data = linkedSignal<{error: string} | SomeData, SomeData>({
    source: this.rawData,
    computation: (previous, current) => {
      if (current.error) {
        return previous; // return the previous value
      }
      return current; // return the new value
    }
  }).asReadonly();
}

This brings us near the conclusion of our journey, where we circle back to the topic of HTTP calls. This is necessary because Angular has introduced fresh tooling for the reactive, signal-driven handling of HTTP requests — the Resource API.

Error Handling with Resources

It's important to mention upfront that all resource variants (resource, rxResource, and httpResource) share the same API structure, particularly in regard to error handling. Therefore, we'll focus on the httpResource scenario, and the principles will carry over to the others without modification.

For those not yet acquainted with the Resource API, I recommend checking out this earlier article for an in-depth introduction.

Now let's examine how to deal with HTTP errors using httpResource. Essentially, we have two typical situations: we either present fallback UI to the user, or we execute some kind of side-effect (again, like showing a toast notification). The first case is quite straightforward:

@Component({
  selector: 'app-root',
  template: `
    <button (click)="data.reload()">Reload Data</button>
    @if (data.hasValue()) {
      <!-- normal UI -->
    } @else if (data.hasError()) {
      <!-- fallback UI in case of error -->  
    }
  `,
})
export class SomeComponent {
  data = httpResource(() => 'http://some-wrong-api.com/wrong-endpoint');
}

We use the hasError method to determine if a prior request failed and to render UI conditionally based on that state. For side-effects, the hasError flag can be tracked by an effect, enabling a straightforward effect to kick in when action is needed:

@Component({...})
export class SomeComponent {
  data = httpResource(() => 'http://some-wrong-api.com/wrong-endpoint');

  constructor() {
    effect(() => {
      if (this.data.hasError()) {
        // show a toast message or something else
      }
    });
  }
}

And there we have it — with the Resource API, error handling is just as simple in your TypeScript logic as it is in your templates, a welcome change from the complexity we faced with computed signals or manually created signals from Observables.

Final Thoughts

As we've seen, the landscape of error handling in Angular is extensive and filled with various tricky situations. Yet, at the same time, Angular arms us with the right utilities to navigate these challenges gracefully. It underscores the notion that the line between a mediocre app and a polished product is often drawn by how well it anticipates and adapts to unexpected user conditions.

A Brief Tangent

Gg2RPJKWwAAHSId.png
My book, Modern Angular, is now available in print! I've dedicated considerable effort to documenting every exciting new Angular feature introduced between versions 12 and 18, covering enhanced dependency injection, RxJS interop, Signals, SSR, Zoneless, and much more.

If you're working on a legacy codebase, I'm confident this book will help you get up to speed with the latest innovations our favorite framework has brought to the table. You can find it here: https://www.manning.com/books/modern-angular

P.S. If you're interested in learning more about error handling and various other Signal-related scenarios, take a look at chapters 6 and 7 of the book ;)


Angular Error Handling — figure 2

Tagged in:

Articles

Last Update: June 09, 2025