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
indeterminatebar 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
determinatewhen you can compute progress from something real (steps, bytes, server-reported percent). - Use
bufferwhen there are two truths at once: “how far the user is” and “how far the system has prepared ahead.” - Use
indeterminatewhen you only know that work is happening. - Use
querywhen 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
MatProgressBarModuletoimportsin yourAppModuleor feature module.
Here’s a quick map of the ‘traditional’ way vs the modern way I ship today:
Older pattern
—
Root AppModule for everything
Many Subjects + manual subscriptions
Toggle a boolean in every call site
Set value on every event
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 bydeterminateandbuffer)bufferValue: number from 0 to 100 (used bybuffer)color: usually‘primary‘(the exact set depends on your Material version/theme)‘accent‘ ‘warn‘
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
aria-label=‘Upload progress‘>
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
<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/:idreturning{ 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
progressbetween 0 and 99 while status isrunning. - 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‘
‘failed‘;
progressPercent: number;
};
@Injectable({ providedIn: ‘root‘ })
export class JobProgressStore {
private readonly _status = signal<'idle'
‘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
*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,DELETEand skipHEAD).
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
determinateover 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-barin 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:
LoadingInterceptorupdates 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
determinateonly for real, measurable progress. - Use
indeterminate(orquery) when time is unknown, and pair it with a clear message. - Use
bufferonly 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.”


