Updated on

When an Angular HTTP request fails, HttpClient hands the failure to the error callback of subscribe as an HttpErrorResponse. Its status property carries the code the server returned, and everything else is a decision about where to send the reader: a 404 to a not-found page, a 500 to an error page, anything else to a message in place.

Here we build the 500 page, put the branching in one functional interceptor, and register it once so that every request the application makes runs through it. The owner details page follows, and it needs no error handling of its own, which is the point of moving the branching out of the components.

For the complete navigation and all the basic instructions of the Angular series, check out: Introduction of the Angular series.

For the previous part check out: Angular Lazy Loading: Load Routes on Demand, which put the owner feature behind a lazy route and fetched the owner list. This article handles what happens when that request fails.

To download the source code for this article, you can visit the ErrorHandling folder in our GitHub repository. The source code for the whole Angular series is here.
Our client sends its requests to the ASP.NET Core Web API from the first half of this series. To follow along, run that API from the UsingRepositoryForWriteRequests folder in our GitHub repository on its http profile, which listens on http://localhost:5000, the address our environment file points to.

How Do We Show a 404 or 500 Error Page in Angular?

An Angular error page is an ordinary component on an ordinary route. There is no special page type for failures: /404 and /500 are routes like /home, and the error handling navigates to them.

Two routes do the work. A path of 404 renders a not-found component, a path of 500 renders an internal-server component, and a wildcard route redirects anything unmatched to /404.

The wildcard has to be last. The router takes the first route whose path matches, so a ** entry placed above others swallows everything defined below it.

Navigating away is not the only option, and it is worth choosing deliberately. A redirect replaces whatever the reader had on screen and changes the address bar, which suits a failure they cannot recover from.

A message rendered in place suits the opposite case, where the request can simply be retried and the rest of the page is still useful.

The 404 page already exists from the routing part of this series, so the one we are missing is the 500. In the error-pages folder, we are going to create a new component by typing the Angular CLI command:

ng g component error-pages/internal-server --skip-tests

Then, let’s modify the internal-server.ts file:

import { Component } from '@angular/core';

@Component({
  selector: 'app-internal-server',
  styleUrl: './internal-server.css',
  templateUrl: './internal-server.html',
})
export class InternalServer {
  errorMessage = '500 SERVER ERROR, CONTACT ADMINISTRATOR!!!!';
}

The component holds one message and nothing else. The CLI generates it standalone, so no module has to declare it anywhere.

Then, let’s modify the internal-server.html file:

<p>{{errorMessage}}</p>

Additionally, we are going to modify the internal-server.css file:

p {
  font-weight: bold;
  font-size: 50px;
  text-align: center;
  color: #c72d2d;
}

Finally, let’s modify the routes array in the app.routes.ts file:

import { Routes } from '@angular/router';

import { Home } from './home/home';
import { NotFound } from './error-pages/not-found/not-found';
import { InternalServer } from './error-pages/internal-server/internal-server';

export const routes: Routes = [
  { path: 'home', component: Home },
  { path: 'owner', loadChildren: () => import('./owner/owner.routes').then(m => m.routes) },
  { path: '404', component: NotFound },
  { path: '500', component: InternalServer },
  { path: '', redirectTo: '/home', pathMatch: 'full' },
  { path: '**', redirectTo: '/404' }
];

Both error pages are ordinary routed components, reached the same way /home is. Anything the router cannot match, a stray /nope/whatever included, ends up on /404 through that last entry.

That’s it. We have created our component, and it is time to send failing requests to it.

How Do We Handle HTTP Errors in Angular?

HttpClient reports a failure as an HttpErrorResponse delivered to the error callback of subscribe, not as a thrown exception we can catch.

Its status property is what we branch on. A status of 0 means the request never reached the server, so there is nothing useful in the body. Any other value is the code the server sent back.

Two more properties carry the detail. error holds the parsed response body when there was one, and statusText holds the reason phrase. They are not the same type, which is why assigning either straight into a string field is a mistake.

Where the branching lives is the real decision. A service that every component injects and calls from its own error callback works, but it has to be wired in at each call site.

That is one forgotten subscribe away from a failure nobody handles, which is the problem an interceptor removes.

The diagram below follows one failed request from the component call to the page the user ends up on.

An Angular HTTP failure passing through the error interceptor, which branches on the status code to the 500 page, the 404 page, or a message that is built but not shown yet.

In the shared/interceptors folder, we are going to create a new interceptor and name it error:

ng g interceptor shared/interceptors/error --skip-tests

The CLI writes a functional interceptor by default and appends the type to the file name, so we get error-interceptor.ts holding a constant called errorInterceptor. Let’s modify it:

import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { EMPTY, catchError } from 'rxjs';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);

  const createErrorMessage = (error: HttpErrorResponse): string =>
    error.error ? error.error : error.statusText;

  const handle500Error = () => {
    router.navigate(['/500']);
  };

  const handle404Error = () => {
    router.navigate(['/404']);
  };

  const handleOtherError = (error: HttpErrorResponse) => {
    createErrorMessage(error); // this will be fixed later
  };

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 500) {
        handle500Error();
      }
      else if (error.status === 404) {
        handle404Error();
      }
      else {
        handleOtherError(error);
      }

      return EMPTY;
    })
  );
};

An interceptor is a plain function of the request and the next handler, which is the form Angular’s HTTP interceptors guide asks for: “Our recommendation is to use functional interceptors because they have more predictable behavior, especially in complex setups.” We take the Router with inject(), because a function has no constructor to inject into, pass the request on with next(req), and let catchError see whatever comes back as a failure.

Inside catchError we check the status code and call the matching helper, exactly as a hand-called service would have: a 500 navigates to /500, a 404 to /404, and anything else goes to handleOtherError, which is not finished yet.

createErrorMessage builds the text for that unfinished case: the response body when there is one, the status text otherwise. It is worth knowing what it holds before rendering it anywhere, because error.error is the parsed response body rather than a string. The modal that shows the message is built in part 12 and wired into this interceptor in part 13.

The last line is the one worth pausing on. EMPTY emits nothing and completes immediately, so a failed request never produces a value: the component’s next callback does not run, and no component needs an error callback to keep the failure quiet.

One registration puts the interceptor in front of every request. Let’s modify the app.config.ts file:

import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { errorInterceptor } from './shared/interceptors/error-interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideHttpClient(withInterceptors([errorInterceptor]))
  ]
};

withInterceptors() takes the interceptor functions to run, in order, and provideHttpClient() is the provider we already added in part 9. Nothing else in the application changes.

That is the whole difference from a hand-called service. The owner list from part 10 subscribes exactly as it did:

private getAllOwners = () => {
  const apiAddress: string = 'api/owner';
  this.repository.getOwners(apiAddress)
  .subscribe({
    next: (own: Owner[]) => this.owners.set(own)
  })
}

There is no error callback here and no error service to inject. The handling runs before the component ever sees the response, and every component we add in the parts that follow inherits it.

We can try it out by changing the code in the server’s GetAllOwners method. As the very first line we can add return NotFound(); or return StatusCode(500, "Some message");, and we are going to be redirected to the right error page.

Preparation for the Owner-Details Component

Let’s continue by creating the owner-details component:

ng g component owner/owner-details --skip-tests

To enable routing to this component, we need to modify the owner.routes.ts file:

import { Routes } from '@angular/router';

import { OwnerList } from './owner-list/owner-list';
import { OwnerDetails } from './owner-details/owner-details';

export const routes: Routes = [
  { path: 'list', component: OwnerList },
  { path: 'details/:id', component: OwnerDetails }
];

As you can see, the new path has the id parameter. So when we click on the Details button, we are going to pass this id to our route and fetch the owner with that exact id in the OwnerDetails component.

But, in order to be able to do that, we need to add a new interface to the _interfaces folder:

export interface Account {
  id: string;
  dateCreated: Date;
  accountType: string;
  ownerId?: string;
}

And modify the Owner interface:

import { Account } from './account.model';

export interface Owner {
  id: string;
  name: string;
  dateOfBirth: Date;
  address: string;

  accounts?: Account[];
}

By using a question mark, we are making our property optional. That question mark comes back later in this article, when the template asks how many accounts an owner has.

To continue, let’s change the owner-list.html file:

<td><button type="button" id="details" class="btn btn-primary"
(click)="getOwnerDetails(owner.id)">Details</button></td>

On a click event, we call the getOwnerDetails function and pass the owner’s id as a parameter. So we need to handle that click event in our owner-list.ts file.

First, let’s add an import statement:

import { Router } from '@angular/router';

Then, we inject the router beside the repository:

private router = inject(Router);

And add the getOwnerDetails(id) function:

public getOwnerDetails = (id: string) => {
  const detailsUrl: string = `/owner/details/${id}`;
  this.router.navigate([detailsUrl]);
}

We create a URI for our details component with the id parameter and then call the navigate function to navigate to that component. The id parameter carries its type because a strict project compiles with noImplicitAny, and an untyped parameter fails the build.

Finally, let’s just add one more function to fetch a single owner inside the owner-repository.service.ts file:

public getOwner(route: string) {
  return this.http.get<Owner>(this.createCompleteRoute(route, this.envUrl.urlAddress));
}

Implementation of the Owner-Details Component

We have all the code to support the owner-details component. Now it is time to implement the logic inside that component.

Firstly, let’s modify the owner-details.ts file:

import { DatePipe } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

import { Owner } from '../../_interfaces/owner.model';
import { OwnerRepositoryService } from '../../shared/services/owner-repository.service';

@Component({
  imports: [DatePipe],
  selector: 'app-owner-details',
  styleUrl: './owner-details.css',
  templateUrl: './owner-details.html',
})
export class OwnerDetails implements OnInit {
  owner = signal<Owner | undefined>(undefined);

  private repository = inject(OwnerRepositoryService);
  private activeRoute = inject(ActivatedRoute);

  ngOnInit() {
    this.getOwnerDetails()
  }

  getOwnerDetails = () => {
    const id: string = this.activeRoute.snapshot.params['id'];
    const apiUrl: string = `api/owner/${id}/account`;

    this.repository.getOwner(apiUrl)
    .subscribe({
      next: (own: Owner) => this.owner.set(own)
    })
  }

}

It is pretty much the same logic as in the owner-list.ts file, except now we have ActivatedRoute imported, because we have to read our id from the route.

The fetched owner lives in a signal, the shape part 10 introduced, and set fills it in when the response arrives. Its type is Owner | undefined, because until then the component holds nothing at all.

All we have to do is to modify the owner-details.html file:

<div class="card card-body bg-light mb-2 mt-2">
  <div class="row">
    <div class="col-md-3">
      <strong>Owner name:</strong>
    </div>
    <div class="col-md-3">
      {{owner()?.name}}
    </div>
  </div>
  <div class="row">
    <div class="col-md-3">
      <strong>Date of birth:</strong>
    </div>
    <div class="col-md-3">
      {{owner()?.dateOfBirth | date: 'dd/MM/yyyy'}}
    </div>
  </div>
  @if ((owner()?.accounts?.length ?? 0) <= 2) {
    <div class="row">
      <div class="col-md-3">
        <strong>Type of user:</strong>
      </div>
      <div class="col-md-3">
        <span class="text-success">Beginner user.</span>
      </div>
    </div>
  } @else {
    <div class="row">
      <div class="col-md-3">
        <strong>Type of user:</strong>
      </div>
      <div class="col-md-3">
        <span class="text-info">Advanced user.</span>
      </div>
    </div>
  }
</div>

<div class="row">
  <div class="col-md-12">
    <div class="table-responsive">
      <table class="table table-striped">
        <thead>
          <tr>
            <th>Account type</th>
            <th>Date created</th>
          </tr>
        </thead>
        <tbody>
          @for (account of owner()?.accounts; track account.id) {
            <tr>
              <td>{{account?.accountType}}</td>
              <td>{{account?.dateCreated | date: 'dd/MM/yyyy'}}</td>
            </tr>
          }
        </tbody>
      </table>
    </div>
  </div>
</div>

Here, we display the owner entity with all the required data, and if the owner has more than two accounts, we show a different block for the type of user.

The ?? 0 in the @if is doing real work. owner() is undefined until the response lands, and accounts is optional on the interface we wrote a moment ago, so owner()?.accounts?.length is a number or undefined. Angular type-checks templates in a strict project and refuses to compare that with 2: without the ?? 0 the build stops with TS2532: Object is possibly 'undefined', and it stops in exactly the same way if we write owner()?.accounts.length and leave the second question mark out.

Finally, we display all the accounts related to this owner:

Owner details page showing a beginner user with two accounts

We can also take a look at the advanced user:

Owner details page showing an advanced user with three accounts

How Do We Handle Errors Globally in Angular?

Angular documents two places for error handling that is not written per request.

A functional HTTP interceptor sees every request HttpClient makes. It is a plain function taking the request and a next handler, registered once through provideHttpClient(withInterceptors([errorInterceptor])), and it can inspect the failure, navigate, and decide whether anything downstream ever sees it. Angular recommends this form over the class-based one because its ordering is predictable.

ErrorHandler is the other, and it works differently. It is the injection token Angular routes uncaught errors through, so it sees failures from anywhere in the application rather than only from HTTP, and its default implementation prints them to the console. Replacing that default takes one provider.

The two answer different questions. The interceptor knows about status codes and can decide where to send the reader. ErrorHandler is the last thing between an unexpected error and a silent failure, which makes it the right home for logging.

MechanismWhat it catchesHow it is registeredReach for it when
subscribe({ error: ... })one failed request, at that call sitenothing to registeronly this component needs to react to this failure
Functional interceptor (HttpInterceptorFn)every failure from HttpClientwithInterceptors() inside provideHttpClient()one place should map status codes to pages and messages
ErrorHandleruncaught errors from anywhere in the application, not only HTTP{provide: ErrorHandler, useClass: MyErrorHandler}logging and reporting, as the last line before a silent failure
Class-based HttpInterceptorsame as the functional interceptorHTTP_INTERCEPTORS plus withInterceptorsFromDi()existing code only. Angular recommends the functional form because its ordering is predictable
@error block inside @defera lazily loaded chunk that failed to arrivein the templateshowing fallback markup when a deferred view cannot load

Angular’s API reference gives ErrorHandler one line: “Provides a hook for centralized exception handling.” Either way, our interceptor only reads what the server decided to send. On the other side of the wire, our ASP.NET Core guides cover how the API decides what to return when a request fails and IExceptionHandler, the current way to do it in .NET.

Conclusion

The error handling in this application now lives in one function, and every request the client makes passes through it. A component that forgets to handle a failure has stopped being a category of bug here.

In this post we have learned:

  • What an HttpErrorResponse carries, and which of its properties to branch on
  • How to build 404 and 500 pages as ordinary routed components, and why the wildcard route stays last
  • How to put the branching in one functional interceptor instead of repeating it in every component
  • How to read a route parameter with ActivatedRoute and render a single owner with their accounts
  • Where a functional interceptor and ErrorHandler fit, and what each one catches

In part 12: Angular @Input and @Output Decorators and Directives, we split the details page into child components and build the modal this article’s other errors need. That modal is what handleOtherError is waiting for: part 12 builds the component, and part 13 wires it into the interceptor and takes the comment out.

If you would rather build these screens with a component library, we cover the same error and details pages built with Angular Material.

Tested with Angular 22.1.5, Angular CLI 22.1.7, ngx-bootstrap 22.0.0, Bootstrap 5.3.8, TypeScript 6.0.3 and Node 24.