← Back to Articles         
Loading indicator using an interceptor

Loading indicator using an interceptor

Published on 01 Oct 2026     7 min read Angular
HTTP Interceptors

How do you implement a Loading Indicator using an Interceptor in Angular?

A loading indicator (spinner) can be implemented globally using an HTTP interceptor.

The idea is simple:

API Request Starts
       ↓
Show Spinner
       ↓
Send HTTP Request
       ↓
Receive Response / Error
       ↓
Hide Spinner

This avoids manually writing show() and hide() in every component or service.


Full Simple Example

We need four main files:

loading.service.ts
loading.interceptor.ts
loading.ts
loading.html

Then register the interceptor in app.config.ts.


1. loading.service.ts

Using Angular Signals:

import { Injectable, signal } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class LoadingService {

  private loadingCount = 0;

  private loadingSignal = signal(false);

  readonly isLoading = this.loadingSignal.asReadonly();

  show(): void {
    this.loadingCount++;

    this.loadingSignal.set(true);
  }

  hide(): void {
    if (this.loadingCount > 0) {
      this.loadingCount--;
    }

    if (this.loadingCount === 0) {
      this.loadingSignal.set(false);
    }
  }
}

2. loading.interceptor.ts

import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { finalize } from 'rxjs';

import { LoadingService } from './loading.service';

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {

  const loadingService = inject(LoadingService);

  loadingService.show();

  return next(req).pipe(

    finalize(() => {
      loadingService.hide();
    })

  );
};

3. loading.ts

import { Component, inject } from '@angular/core';
import { LoadingService } from './loading.service';

@Component({
  selector: 'app-loading',
  standalone: true,
  templateUrl: './loading.html',
  styleUrl: './loading.css'
})
export class Loading {

  loadingService = inject(LoadingService);

}

4. loading.html

@if (loadingService.isLoading()) {

  <div class="loading-overlay">

    <div class="spinner"></div>

    <p>Loading...</p>

  </div>

}

5. loading.css

.loading-overlay {
  position: fixed;

  top: 0;
  left: 0;

  width: 100%;
  height: 100%;

  display: flex;
  flex-direction: column;

  justify-content: center;
  align-items: center;

  background: rgba(255, 255, 255, 0.7);

  z-index: 9999;
}

.spinner {
  width: 50px;
  height: 50px;

  border: 5px solid #ddd;
  border-top: 5px solid #333;

  border-radius: 50%;

  animation: spin 1s linear infinite;
}

@keyframes spin {

  from {
    transform: rotate(0deg);
  }

  to {
    transform: rotate(360deg);
  }

}

6. app.config.ts

Register the interceptor globally.

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

import {
  provideHttpClient,
  withInterceptors
} from '@angular/common/http';

import { loadingInterceptor } from './loading.interceptor';

export const appConfig: ApplicationConfig = {

  providers: [

    provideHttpClient(
      withInterceptors([
        loadingInterceptor
      ])
    )

  ]

};

7. app.ts

Add the loading component to your root component.

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

import { Loading } from './loading';

@Component({
  selector: 'app-root',

  standalone: true,

  imports: [
    RouterOutlet,
    Loading
  ],

  templateUrl: './app.html'
})
export class App {

}

8. app.html

<app-loading />

<router-outlet />

Now the loading component exists globally.


9. product.service.ts

No loading code is required here.

import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Product {
  id: number;
  name: string;
  price: number;
}

@Injectable({
  providedIn: 'root'
})
export class ProductService {

  private http = inject(HttpClient);

  private apiUrl =
    'https://localhost:7001/api/products';

  getProducts(): Observable<Product[]> {

    return this.http.get<Product[]>(
      this.apiUrl
    );

  }
}

When getProducts() runs, the loading indicator automatically appears.


How does it work?

Suppose the component calls:

this.productService.getProducts()
  .subscribe(products => {

    console.log(products);

  });

The request goes through the loading interceptor:

Product Component
       ↓
ProductService
       ↓
HttpClient
       ↓
Loading Interceptor
       │
       ├── loadingService.show()
       │
       ▼
Spinner ON
       │
       ▼
ASP.NET Core Web API
       │
       │ Response
       ▼
finalize()
       │
       ├── loadingService.hide()
       │
       ▼
Spinner OFF

Understanding finalize()

The most important part of this interceptor is:

return next(req).pipe(

  finalize(() => {
    loadingService.hide();
  })

);

finalize() is an RxJS operator that runs when the Observable terminates, including when the HTTP request:

  • completes successfully, or
  • fails with an error.

So:

                HTTP Request
                     |
              +------+------+
              |             |
           Success        Error
              |             |
              +------+------+
                     |
                 finalize()
                     |
                     ↓
                Hide Spinner

This makes it very useful for loading indicators.


Why not simply use tap()?

You could react to responses with tap(), but finalize() is a cleaner choice when the requirement is:

"Whatever happens to this request, clean up the loading state when it finishes."

finalize(() => {
  loadingService.hide();
})

is therefore commonly used.


Why do we need loadingCount?

This is an important point.

You might initially write:

show() {
  this.loadingSignal.set(true);
}

hide() {
  this.loadingSignal.set(false);
}

This works if there is only one API request at a time.

But consider two simultaneous API requests:

Request A ──────────────── 5 seconds

Request B ─────── 2 seconds

Both start:

Request A starts → Spinner ON

Request B starts → Spinner ON

After 2 seconds, Request B completes:

Request B completes
       ↓
Spinner OFF   ❌

But Request A is still running.

That's incorrect.


Using a Request Counter

That's why our service uses:

private loadingCount = 0;

When Request A starts:

loadingCount = 1
Spinner = ON

Request B starts:

loadingCount = 2
Spinner = ON

Request B finishes:

loadingCount = 1
Spinner = ON

Request A finishes:

loadingCount = 0
Spinner = OFF

Therefore:

Request A ───────────────────────┐
                                │
Request B ───────────┐           │
                     │           │
                     ▼           │
                B completed      │
                     │           │
             Count = 1           │
             Spinner ON          │
                                 ▼
                            A completed
                                 │
                            Count = 0
                                 │
                            Spinner OFF

This is much safer when multiple API requests can run concurrently.


Why use a Signal?

The loading state is UI state:

signal(false)

The component can read it directly:

@if (loadingService.isLoading()) {
    <div>Loading...</div>
}

When the interceptor calls:

loadingService.show();

the signal changes:

false → true

Angular updates the UI.

When:

loadingService.hide();

changes it back:

true → false

Angular removes the spinner.


Multiple Interceptors

In a real application, you might have:

provideHttpClient(
  withInterceptors([
    authInterceptor,
    loggingInterceptor,
    loadingInterceptor,
    errorInterceptor
  ])
)

For example:

Component
    ↓
Service
    ↓
HttpClient
    ↓
Auth Interceptor
    │
    ├── Add JWT
    ↓
Logging Interceptor
    │
    ├── Log Request
    ↓
Loading Interceptor
    │
    ├── Spinner ON
    ↓
Error Interceptor
    ↓
ASP.NET Core Web API

When the request finishes, finalize() in the loading interceptor turns off the loading state when all tracked requests are complete.


Can we exclude some API requests?

Yes.

Sometimes you don't want the global spinner for background requests.

For example:

Health check
Background refresh
Analytics
Polling

A simple URL-based approach could be:

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {

  const loadingService = inject(LoadingService);

  if (req.url.includes('/health')) {
    return next(req);
  }

  loadingService.show();

  return next(req).pipe(

    finalize(() => {
      loadingService.hide();
    })

  );
};

For larger applications, using request metadata such as HttpContextToken is cleaner than maintaining many URL checks.


Key Points

  • Use an HTTP interceptor for a global API loading indicator.
  • Create a shared LoadingService.
  • Show the spinner before forwarding the request:
loadingService.show();
  • Send the request:
next(req)
  • Use RxJS finalize() to hide the spinner:
return next(req).pipe(
  finalize(() => {
    loadingService.hide();
  })
);
  • finalize() runs when the request terminates, whether it succeeds or errors.
  • Use a request counter when multiple HTTP requests can execute simultaneously.
  • Angular Signals work well for representing loading UI state.
  • The individual API services don't need spinner logic.

Interview Questions and Answers

1. How do you implement a global loading indicator in Angular?

Create a LoadingService containing the loading state and use an HTTP interceptor to show the loader before the request and hide it in finalize().

loadingService.show();

return next(req).pipe(
  finalize(() => loadingService.hide())
);

2. Why use finalize()?

finalize() executes when the HTTP Observable terminates, whether the request succeeds or fails. Therefore, it is useful for cleanup operations such as hiding a spinner.

3. Why shouldn't we simply use a boolean for the loading state?

A simple boolean can fail when multiple HTTP requests execute concurrently. One request may finish and turn off the spinner while another request is still running.

A counter solves this:

Request 1 starts  → Count = 1
Request 2 starts  → Count = 2

Request 2 ends    → Count = 1 → Spinner ON
Request 1 ends    → Count = 0 → Spinner OFF

4. Why use a Signal for the loading state?

A signal provides reactive UI state. When its value changes, Angular can update the loading component automatically.

private loadingSignal = signal(false);

readonly isLoading =
  this.loadingSignal.asReadonly();

5. Should every HTTP request display the global spinner?

Not necessarily. Background operations, polling, health checks, and similar requests can be excluded when they should not block or distract the UI.

Interview one-line answer

A global Angular loading indicator can be implemented with an HTTP interceptor that calls a shared LoadingService before forwarding the request and uses RxJS finalize() to hide the spinner after completion or error; a request counter should be used to correctly handle concurrent API calls.

← Back to Articles