Angular Material Progress Bar: Practical Patterns for Honest Loading UI

I still see teams treat loading UI like an afterthought: a spinner slapped on a button, a full-page overlay that blocks everything, or a progress indicator that jumps from 0% to 100% in one frame. The result is the same: users don’t trust what’s happening. A good progress bar is a promise you keep—either you show real progress (when you can), or you clearly signal that time is unknown (when it is).

Angular Material’s mat-progress-bar hits the sweet spot for most web apps: it’s accessible by default, theme-aware, and easy to wire to real signals from HTTP, uploads, background jobs, and route preloading. The tricky part isn’t drawing the bar—it’s choosing the right mode, shaping progress data so it feels honest, and avoiding UI jank when values change frequently.

I’ll walk you through the four modes (determinate, indeterminate, buffer, query), modern Angular setup patterns (standalone-first, but I’ll also show NgModule), and the real-world glue code I use: interceptors, services, RxJS, and signals. Along the way, I’ll call out common mistakes that make progress indicators feel broken, and the performance and accessibility details that separate a polished app from a frustrating one.

Picking the right progress indicator

A linear progress bar works best when:

  • You’re showing progress for a task that takes more than a blink (roughly >300–500ms), especially if the user is waiting for navigation, uploads, or multi-step work.
  • The user benefits from knowing how far along the task is (downloading an invoice PDF, processing images, syncing offline changes).
  • You want a subtle, non-blocking signal at the top of a page while content streams in.

I avoid a progress bar when:

  • The action is typically instantaneous; adding a bar can make the UI feel slower.
  • You can’t estimate progress and you’re already showing a blocking dialog—an indeterminate bar can be fine, but don’t stack spinners, overlays, and bars.
  • The user is doing high-focus work (typing in a form) and the indicator would steal attention; consider a small inline indicator near the affected section.

A simple analogy I use with teams: a determinate bar is like a train schedule (you know the stops), and an indeterminate bar is like waiting for an elevator (you know it’s coming, you don’t know when). Buffer mode is like streaming video (loaded vs played), and query mode is like a system checking multiple sources.

A quick decision cheat-sheet (how I pick modes)

If you’re deciding in a code review, I use these rules of thumb:

  • Use determinate when you can compute progress from something real (steps, bytes, server-reported percent).
  • Use buffer when there are two truths at once: “how far the user is” and “how far the system has prepared ahead.”
  • Use indeterminate when you only know that work is happening.
  • Use query when the app is “checking/negotiating” rather than “processing,” and you want a more lively, scanning feel.

The goal isn’t to show motion. The goal is to reduce uncertainty without lying.

Setup in modern Angular (standalone + NgModule)

Angular Material is installed through the Angular CLI schematic:

ng add @angular/material

From there, you need the progress bar module:

import { MatProgressBarModule } from ‘@angular/material/progress-bar‘;

I see two common project shapes in 2026:

  • Standalone-first apps (recommended for new work): import Material modules directly in a standalone component’s imports, or provide them via a shared UI component.
  • NgModule apps (common in older codebases): add MatProgressBarModule to imports in your AppModule or feature module.

Here’s a quick map of the ‘traditional’ way vs the modern way I ship today:

Topic

Older pattern

Modern pattern I recommend —

— App structure

Root AppModule for everything

Standalone components + route-level providers UI state

Many Subjects + manual subscriptions

Signals for local UI state, RxJS for streams Global loading

Toggle a boolean in every call site

HTTP interceptor + shared loading store Progress updates

Set value on every event

Throttle updates (about 50–150ms)

Minimal standalone example (runnable)

This is a small app that renders a determinate progress bar driven by a signal. The important detail: Angular Material expects value in the 0–100 range.

main.ts

import { bootstrapApplication } from ‘@angular/platform-browser‘;

import { provideAnimations } from ‘@angular/platform-browser/animations‘;

import { AppComponent } from ‘./app/app.component‘;

bootstrapApplication(AppComponent, {

providers: [provideAnimations()],

});

app.component.ts

import { ChangeDetectionStrategy, Component, computed, signal } from ‘@angular/core‘;

import { MatProgressBarModule } from ‘@angular/material/progress-bar‘;

@Component({

selector: ‘app-root‘,

standalone: true,

imports: [MatProgressBarModule],

templateUrl: ‘./app.component.html‘,

changeDetection: ChangeDetectionStrategy.OnPush,

})

export class AppComponent {

private readonly completedSteps = signal(2);

private readonly totalSteps = signal(5);

readonly percent = computed(() => {

const total = this.totalSteps();

if (total <= 0) return 0;

return Math.round((this.completedSteps() / total) * 100);

});

advance() {

this.completedSteps.update(v => Math.min(v + 1, this.totalSteps()));

}

}

app.component.html

Processing invoices

Step progress: {{ percent() }}%

<mat-progress-bar

mode=‘determinate‘

[value]=‘percent()‘

aria-label=‘Invoice processing progress‘>


NgModule setup (when you’re in a module-based app)

If you have app.module.ts, add the module import:

import { NgModule } from ‘@angular/core‘;

import { BrowserModule } from ‘@angular/platform-browser‘;

import { BrowserAnimationsModule } from ‘@angular/platform-browser/animations‘;

import { MatProgressBarModule } from ‘@angular/material/progress-bar‘;

import { AppComponent } from ‘./app.component‘;

@NgModule({

declarations: [AppComponent],

imports: [BrowserModule, BrowserAnimationsModule, MatProgressBarModule],

bootstrap: [AppComponent],

})

export class AppModule {}

The properties I actually use (and what they mean)

Before we go mode-by-mode, here’s the small mental model that prevents 80% of “why does this look weird?” issues:

  • mode: ‘determinate‘ ‘indeterminate‘

    ‘buffer‘‘query‘

  • value: number from 0 to 100 (used by determinate and buffer)
  • bufferValue: number from 0 to 100 (used by buffer)
  • color: usually ‘primary‘ ‘accent‘

    ‘warn‘ (the exact set depends on your Material version/theme)

Also: mat-progress-bar is meant to be a progress indicator, not a layout element. I keep it thin, pinned, and predictable.

Mode 1: determinate (real percentage)

determinate is the default mode, and it’s the one I reach for when I can compute real progress.

What I wire into a determinate bar

Good sources of real progress:

  • Multi-step workflows (checkout steps, onboarding steps, wizard forms)
  • File uploads and downloads with byte counts
  • Background jobs that report progress from the server (queue workers, batch processing)

Bad sources:

  • Guessing based on time. If you can’t measure progress, don’t fake it with a percentage that stalls at 92%.

Determinate with an upload (runnable pattern)

When you upload a file with Angular’s HttpClient, you can request progress events. The progress bar value becomes loaded / total.

document-upload.component.ts

import { ChangeDetectionStrategy, Component, signal } from ‘@angular/core‘;

import { HttpClient, HttpEvent, HttpEventType } from ‘@angular/common/http‘;

import { MatProgressBarModule } from ‘@angular/material/progress-bar‘;

@Component({

selector: ‘app-document-upload‘,

standalone: true,

imports: [MatProgressBarModule],

templateUrl: ‘./document-upload.component.html‘,

changeDetection: ChangeDetectionStrategy.OnPush,

})

export class DocumentUploadComponent {

readonly percent = signal(0);

readonly uploading = signal(false);

constructor(private readonly http: HttpClient) {}

onFileSelected(file: File | null) {

if (!file) return;

const form = new FormData();

form.append(‘document‘, file);

this.uploading.set(true);

this.percent.set(0);

this.http

.post(‘/api/documents‘, form, {

reportProgress: true,

observe: ‘events‘,

})

.subscribe({

next: (event: HttpEvent) => {

if (event.type === HttpEventType.UploadProgress) {

const total = event.total ?? 0;

if (total > 0) {

this.percent.set(Math.round((event.loaded / total) * 100));

}

}

if (event.type === HttpEventType.Response) {

this.percent.set(100);

this.uploading.set(false);

}

},

error: () => {

this.uploading.set(false);

},

});

}

}

document-upload.component.html

<mat-progress-bar

[mode]=‘uploading() ? "determinate" : "determinate"‘

[value]=‘percent()‘

aria-label=‘Upload progress‘>

{{ percent() }}%

A small but important tip: I often clamp progress and avoid a ‘stuck at 99%’ effect by setting 100% only on the final response event, not when loaded === total (proxies and server-side processing can still run after the bytes are sent).

Determinate for multi-step workflows (less noisy than percent-of-time)

In business apps, a surprising amount of “progress” is actually step-based. If the user must complete 5 steps, then a progress bar is basically a breadcrumb with confidence.

What I like about steps:

  • It’s honest: each step is real.
  • It doesn’t flicker: it only changes when a step completes.
  • It’s accessible: you can announce “Step 3 of 5 completed.”

A pattern I use is to compute percent, but also render the step label so the user doesn’t think the percent is time-based.

wizard-progress.component.html

Onboarding
Step {{ currentStep() }} of {{ totalSteps() }}

<mat-progress-bar

mode=‘determinate‘

[value]=‘percent()‘

aria-label=‘Onboarding step progress‘>

Determinate for server-side jobs (polling + smoothing)

Uploads are easy because the browser can report bytes. Background jobs are trickier because you need the server to report progress.

If your backend can expose something like:

  • GET /api/jobs/:id returning { status: ‘running‘ ‘done‘

    ‘failed‘, progressPercent: number }

…then the frontend becomes a simple poll.

My “don’t make it feel broken” rules for job progress:

  • Clamp progress between 0 and 99 while status is running.
  • Only show 100 when status is done.
  • Throttle UI updates so a noisy backend doesn’t cause layout jank.

job-progress.store.ts

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

import { HttpClient } from ‘@angular/common/http‘;

import { interval, switchMap, takeWhile, catchError, of, map, distinctUntilChanged } from ‘rxjs‘;

type JobResponse = {

status: ‘running‘

‘done‘

‘failed‘;

progressPercent: number;

};

@Injectable({ providedIn: ‘root‘ })

export class JobProgressStore {

private readonly _status = signal<'idle'

‘running‘

‘done‘‘failed‘>(‘idle‘);

private readonly _percent = signal(0);

readonly status = this._status.asReadonly();

readonly percent = this._percent.asReadonly();

readonly isActive = computed(() => this._status() === ‘running‘);

constructor(private readonly http: HttpClient) {}

trackJob(jobId: string) {

this._status.set(‘running‘);

this._percent.set(0);

return interval(700).pipe(

switchMap(() => this.http.get(/api/jobs/${jobId})),

map(res => {

const raw = Number.isFinite(res.progressPercent) ? res.progressPercent : 0;

const clamped = Math.max(0, Math.min(99, Math.round(raw)));

return { status: res.status, percent: clamped };

}),

distinctUntilChanged((a, b) => a.status === b.status && a.percent === b.percent),

takeWhile(v => v.status === ‘running‘, true),

catchError(() => of({ status: ‘failed‘ as const, percent: 0 }))

).subscribe(v => {

if (v.status === ‘done‘) {

this._percent.set(100);

this._status.set(‘done‘);

} else if (v.status === ‘failed‘) {

this._status.set(‘failed‘);

} else {

this._percent.set(v.percent);

}

});

}

}

This is intentionally boring code. Boring is good here: progress indicators are a trust feature.

Mode 2: indeterminate (unknown time)

indeterminate is for work where you can’t know progress, but you do know that something is happening. Typical cases:

  • Waiting on an API call with variable latency
  • Route navigation while resolvers fetch data
  • Server processing without progress reporting

The UX goal is simple: reassure without lying. I keep indeterminate bars subtle and avoid blocking overlays unless the user truly can’t proceed.

A global top loading bar with an HTTP interceptor

This pattern is one of the highest ROI improvements you can make: a thin bar at the top whenever there are active HTTP requests.

loading-store.ts

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

@Injectable({ providedIn: ‘root‘ })

export class LoadingStore {

private readonly activeRequests = signal(0);

readonly isLoading = () => this.activeRequests() > 0;

start() {

this.activeRequests.update(v => v + 1);

}

stop() {

this.activeRequests.update(v => Math.max(0, v – 1));

}

}

loading.interceptor.ts

import { Injectable } from ‘@angular/core‘;

import {

HttpEvent,

HttpHandler,

HttpInterceptor,

HttpRequest,

} from ‘@angular/common/http‘;

import { Observable, finalize } from ‘rxjs‘;

import { LoadingStore } from ‘./loading-store‘;

@Injectable()

export class LoadingInterceptor implements HttpInterceptor {

constructor(private readonly loading: LoadingStore) {}

intercept(req: HttpRequest, next: HttpHandler): Observable<HttpEvent> {

// Skip long polling, SSE, or endpoints you intentionally don’t want.

if (req.headers.has(‘x-skip-loading‘)) {

return next.handle(req);

}

this.loading.start();

return next.handle(req).pipe(finalize(() => this.loading.stop()));

}

}

If you’re in a standalone app, provide it in your app config:

app.config.ts

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

import { provideHttpClient, withInterceptorsFromDi } from ‘@angular/common/http‘;

import { HTTP_INTERCEPTORS } from ‘@angular/common/http‘;

import { provideAnimations } from ‘@angular/platform-browser/animations‘;

import { LoadingInterceptor } from ‘./loading.interceptor‘;

export const appConfig: ApplicationConfig = {

providers: [

provideAnimations(),

provideHttpClient(withInterceptorsFromDi()),

{ provide: HTTP_INTERCEPTORS, useClass: LoadingInterceptor, multi: true },

],

};

Now render an indeterminate bar when loading:

app-shell.component.ts

import { ChangeDetectionStrategy, Component } from ‘@angular/core‘;

import { MatProgressBarModule } from ‘@angular/material/progress-bar‘;

import { LoadingStore } from ‘./loading-store‘;

@Component({

selector: ‘app-shell‘,

standalone: true,

imports: [MatProgressBarModule],

templateUrl: ‘./app-shell.component.html‘,

changeDetection: ChangeDetectionStrategy.OnPush,

})

export class AppShellComponent {

constructor(readonly loading: LoadingStore) {}

}

app-shell.component.html

<mat-progress-bar

*ngIf=‘loading.isLoading()‘

mode=‘indeterminate‘

aria-label=‘Loading‘>

Two things I do to make this feel good:

  • I add a short delay (around 150–250ms) before showing it, so fast requests don’t flash the bar.
  • I keep it visible for a minimum duration (around 200–400ms) once shown, so it doesn’t flicker.

That’s not about being fancy—it’s about avoiding visual noise.

The “delay + minimum visible time” pattern (the missing piece)

If you implement a global loading bar and don’t add hysteresis, users will see it flicker on every tiny request. That trains them to ignore it.

I solve this by separating:

  • “Should we consider ourselves loading?” (raw truth: active request count)
  • “Should we show the UI right now?” (smoothed truth: delay + min duration)

Here’s a lightweight store that does that smoothing with RxJS but exposes a signal.

loading-visibility.store.ts

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

import { BehaviorSubject, combineLatest, map, distinctUntilChanged, switchMap, timer } from ‘rxjs‘;

@Injectable({ providedIn: ‘root‘ })

export class LoadingVisibilityStore {

private readonly active$ = new BehaviorSubject(0);

private readonly shown = signal(false);

// Tweak these per app. I usually start here.

private readonly showDelayMs = 200;

private readonly minShowMs = 300;

readonly isShown = this.shown.asReadonly();

start() {

this.active$.next(this.active$.value + 1);

}

stop() {

this.active$.next(Math.max(0, this.active$.value – 1));

}

init() {

// Run once (e.g. in AppShell constructor). Keeps code simple.

const activeBool$ = this.active$.pipe(map(n => n > 0), distinctUntilChanged());

activeBool$

.pipe(

switchMap(isActive => {

if (isActive) {

// Wait before showing to avoid flash.

return timer(this.showDelayMs).pipe(map(() => ({ show: true })));

}

// If it was shown, keep it visible a bit to avoid flicker.

if (this.shown()) {

return timer(this.minShowMs).pipe(map(() => ({ show: false })));

}

return timer(0).pipe(map(() => ({ show: false })));

})

)

.subscribe(v => this.shown.set(v.show));

}

}

You can wire your interceptor to LoadingVisibilityStore instead of the simpler LoadingStore. The result is the same correctness, but much better perceived quality.

Don’t count everything: exclude long-lived requests

One common “why is the loading bar always on?” bug is that you count SSE, long polling, websockets, or analytics beacons as “loading.”

My approach:

  • Exclude by header (x-skip-loading), as shown.
  • Exclude by URL pattern (e.g. /events, /metrics, /ping).
  • Optionally exclude by method (I often count only GET, POST, PUT, DELETE and skip HEAD).

What matters is consistency: if the bar is on, users assume the app is waiting on something important.

Mode 3: buffer (loaded vs consumed)

Buffer mode is the most misunderstood. It’s not ‘almost determinate’. It’s for two related values:

  • value: current progress (played, processed, consumed)
  • bufferValue: how much is available ahead (downloaded, prepared, cached)

I use buffer mode when there’s a pipeline:

  • Streaming or chunked downloads
  • Paginated prefetch where you load ahead while the user reads
  • Media-like experiences (audio, video) or large data previews

Buffer mode example: reading while prefetching

Imagine a report viewer that loads pages in the background. The user is on page 12 of 100, and your app has already fetched up to page 40. That’s classic buffer mode.

report-buffer.component.ts

import { ChangeDetectionStrategy, Component, computed, signal } from ‘@angular/core‘;

import { MatProgressBarModule } from ‘@angular/material/progress-bar‘;

@Component({

selector: ‘app-report-buffer‘,

standalone: true,

imports: [MatProgressBarModule],

templateUrl: ‘./report-buffer.component.html‘,

changeDetection: ChangeDetectionStrategy.OnPush,

})

export class ReportBufferComponent {

private readonly totalPages = signal(100);

private readonly currentPage = signal(12);

private readonly prefetchedThrough = signal(40);

readonly value = computed(() => Math.round((this.currentPage() / this.totalPages()) * 100));

readonly bufferValue = computed(() => Math.round((this.prefetchedThrough() / this.totalPages()) * 100));

nextPage() {

this.currentPage.update(p => Math.min(p + 1, this.totalPages()));

// Fake background prefetch: keep the buffer ahead.

this.prefetchedThrough.update(p => Math.min(Math.max(p, this.currentPage() + 15), this.totalPages()));

}

}

report-buffer.component.html

Quarterly report

<mat-progress-bar

mode=‘buffer‘

[value]=‘value()‘

[bufferValue]=‘bufferValue()‘

aria-label=‘Report loading and reading progress‘>

Reading progress: {{ value() }}% (buffered: {{ bufferValue() }}%)

If you use buffer mode for network downloads, be careful: many HTTP responses don’t provide meaningful incremental download events in browsers. Upload progress is common; download progress is more limited depending on server headers, compression, and fetch semantics.

Buffer mode in real apps: what I map to value and bufferValue

A few practical mappings I’ve used:

  • Infinite list:

value: percent scrolled through currently loaded items (or current index / total estimate)

bufferValue: percent of items prefetched in the next chunk

  • Offline sync:

value: processed changes / total changes

bufferValue: fetched changes / total changes (downloaded but not applied yet)

  • Editor with autosave + background analysis:

value: “saved” checkpoint percent (or step count)

bufferValue: analysis progress ahead of what’s visible (linting, indexing)

The key is that buffer mode communicates two layers of certainty. If you can’t define those layers clearly, stick to determinate or indeterminate.

Mode 4: query (continuous motion)

Query mode is a specialized UX: a continuously moving bar that suggests the app is checking or preparing something. I see it used less often, but it can be a nice fit for:

  • Search pages that are “querying multiple sources” (even if the backend is one API, the UX can feel like a scan)
  • Auth flows where the app is verifying a session, refreshing a token, or negotiating access
  • Startup “bootstrap” sequences where you want to imply activity but not promise progress

In practice, I treat query like a stylistic variant of indeterminate: it’s still unknown time, but it has a different emotional tone.

Query mode example: checking account state

I like query mode for “checking” because it reads like “we’re looking around,” not “we’re processing your file.”

account-check.component.html

Signing you in

Checking your account and workspace access…

<mat-progress-bar

mode=‘query‘

aria-label=‘Checking account‘>

I keep query bars short-lived. If it turns into a real long wait, I switch to a more explicit message: what’s happening, what the user can do, and whether they can navigate away.

Common mistakes that make progress indicators feel broken

These issues show up constantly in production apps, even well-built ones.

Mistake 1: Showing progress for everything

If every click triggers a global loading bar, users stop associating it with meaningful work. My fix:

  • Only show a global bar for navigation and “page-level” work.
  • For small actions (like “Save” on a form), show inline feedback near the control.

Mistake 2: Fake determinate progress that stalls

A determinate bar that pauses at 90–95% destroys trust. Users feel trapped: “It says 95%, why isn’t it done?”

If you can’t measure progress, don’t use a percent. Use indeterminate and message the stage (e.g. “Generating PDF…”, “Applying changes…”, “Finalizing…”).

Mistake 3: Updating the bar too frequently

If you set [value] on every tiny event, you can create subtle jank (especially on lower-end devices) and you can flood change detection.

My baseline:

  • For noisy streams, throttle updates to ~50–150ms.
  • For upload progress, you can often keep it simple, but still consider a small throttle if you see churn.

An RxJS trick I use:

import { auditTime } from ‘rxjs‘;

progressEvents$.pipe(auditTime(100)).subscribe(…)

That keeps the UI responsive without feeling laggy.

Mistake 4: Not handling error and cancellation states

A progress bar that disappears on error is confusing: did it fail or finish?

What I do:

  • On error, keep the bar visible briefly and show a clear error message.
  • Consider switching color=‘warn‘ for a moment if the bar is prominent.
  • On cancel, reset the bar to 0 and show “Canceled” or revert to idle UI.

Mistake 5: Counting background traffic as “loading”

If your interceptor counts metrics, heartbeat, or prefetch calls, the bar will stay on forever. Users then assume the app is broken.

I keep a short allowlist or blocklist and I’m deliberate about what “loading” means.

Performance and UI smoothness (what actually matters)

Progress indicators are small, but they sit at the top of your visual hierarchy. Tiny performance problems become very noticeable.

Choose stable placement

If the bar pushes content down when it appears, the entire layout shifts. That’s distracting.

What I prefer:

  • Reserve space (e.g., a fixed-height header area) so the bar doesn’t cause reflow.
  • Or overlay it in a sticky header so content doesn’t move.

Avoid flashing: delay and minimum show time

I mentioned this earlier, but it’s worth repeating because it’s the biggest perceived-quality win:

  • Delay showing the bar slightly.
  • Once shown, keep it for a minimum duration.

It’s a classic UI smoothing technique that makes your app feel calmer.

Use OnPush and localize state

I default to ChangeDetectionStrategy.OnPush for components that host progress indicators, and I keep the state as local as possible:

  • Signals for local UI values.
  • RxJS streams for external sources.
  • A small store/service for global state.

If you’re not careful, it’s easy to accidentally re-render large trees just because a global progress$ emitted.

Throttle progress updates, but don’t lag

There’s a balance:

  • Too frequent: janky.
  • Too infrequent: feels stuck.

In most apps, updating 6–20 times per second is plenty. That corresponds roughly to 50–150ms throttling.

Accessibility and inclusive UX

Angular Material does a lot for you, but accessible loading UI still requires product decisions.

Provide a label (and consider a message)

I always add aria-label (or a nearby visible label). A screen reader user should know what the progress refers to.

Examples:

  • “Loading invoices”
  • “Uploading signed contract”
  • “Syncing offline changes”

Don’t over-announce noisy progress

A determinate progress bar that changes rapidly can cause repeated announcements if you also wire it to live regions.

My rule:

  • Let the bar be visual.
  • Announce only meaningful milestones (start, 25/50/75%, done, failed), or step changes in workflows.

A simple milestone announcer can be as basic as:

if (percent === 0) announce(‘Upload started‘);

if (percent === 100) announce(‘Upload complete‘);

Reduced motion considerations

Progress indicators are motion by design. For users who prefer reduced motion, consider:

  • Favoring determinate over indefinite animations when you can.
  • Minimizing the use of full-width, high-contrast animated bars for non-critical work.

Even without special handling, the biggest win is to avoid showing animated indicators for tiny actions.

Keyboard and focus

The progress bar itself is not typically focusable, which is good. The accessibility risk is indirect:

  • If you show an overlay while loading, you might trap focus incorrectly.
  • If you disable controls, you might not provide a clear reason.

I treat a progress bar as non-blocking unless the action truly prevents interaction.

Practical scenarios (patterns I reuse)

This is where mat-progress-bar shines: small, reusable patterns that scale across an app.

Scenario 1: Page-level loading (route navigation)

Users experience navigation as “the app is doing something.” If your app has resolvers, guards, or data fetching tied to routes, a top loading bar is perfect.

A straightforward approach is to combine router events with HTTP loading:

  • Router navigation start: show.
  • Navigation end/cancel/error: stop.
  • HTTP requests during navigation: also counted.

I keep this logic in a single “app shell loading” store so individual pages don’t implement their own global spinners.

Scenario 2: Inline section loading (skeleton + bar)

For dashboards and complex pages, a global top bar alone can be too vague. I like a hybrid:

  • Global indeterminate bar for the “page is working” signal.
  • Inline determinate or indeterminate indicator for a specific panel.

For example:

  • Top bar while route resolves.
  • Small mat-progress-bar in a card header while that card refreshes.

This avoids the “everything is blocked” feeling.

Scenario 3: Button actions (don’t punish the user)

If the user clicks “Save,” a full-width progress bar can be overkill. Instead:

  • Disable the button.
  • Show a small inline indicator near the button or at the top of the form section.
  • If the action can take a while (export, upload), show a determinate bar.

I try not to stack indicators: if the button has a spinner, don’t also show a global bar unless it’s a page-level transition.

Scenario 4: Uploads (percent + human message)

A percentage without context can feel cold. I often pair:

  • The bar (percent)
  • A short message (file name, bytes uploaded if you have it)
  • A cancel affordance

Even a simple “Uploading contract.pdf…” increases trust.

Testing and maintainability tips

Progress UI is easy to break because it’s cross-cutting.

Keep progress logic in one place

If you manage global loading, avoid sprinkling loading = true/false across components.

A better structure:

  • LoadingInterceptor updates a store.
  • App shell renders the bar.
  • Exceptions are opt-out via a header or config.

Unit test the store, not the pixels

I test:

  • active request counting
  • delay/min show behavior
  • that stop() never drops below 0

Then I do light template tests to ensure the bar is shown/hidden when expected.

Make it observable in production

If users complain “the app is stuck loading,” you want to know why.

At minimum, I log:

  • how long the loading bar was shown
  • the count of concurrent requests (sampled)
  • whether a specific endpoint tends to keep it on

This can be as simple as a debug-only console log or a lightweight telemetry event.

Wrapping up

mat-progress-bar is one of those components that looks simple but carries a lot of UX responsibility. When you pick the right mode, feed it honest data, and smooth the display with small timing rules, the whole app feels faster—even when it isn’t.

If you take only a few things from this:

  • Use determinate only for real, measurable progress.
  • Use indeterminate (or query) when time is unknown, and pair it with a clear message.
  • Use buffer only when you truly have two progress values that matter.
  • Add a delay + minimum visible time to prevent flicker.
  • Don’t count long-lived background requests as “loading.”

That’s the difference between “we added a loading bar” and “the app feels reliable.”

Scroll to Top