Designing for Failed Requests in Angular: States, Interceptors and Retries
Learn how to classify HTTP failures in Angular, clean up loading state, centralize handling in interceptors, retry safely and show messages users can act on.
This article is published in English.
Most Angular components are written for the happy path: 200 OK, data in a property, template rendered. In production, connections drop, sessions expire, backends crash and users double-click submit buttons. The patterns below let an Angular application notice those failures, explain them to the user, recover where that is safe and give developers enough information to fix the cause.
The cost of ignoring the error callback
A typical component request subscribes, assigns the response and does nothing else.
this.http.get('/api/accounts').subscribe({
next: (response) => {
this.accounts = response;
}
});
If the server answers with a 500, nothing visible happens. The page stays empty or a spinner keeps turning, and the only trace is a console entry the user never opens.
The minimum improvement is an error handler that logs the failure and stores a readable message for the template.
this.http.get('/api/accounts').subscribe({
next: (response) => {
this.accounts = response;
},
error: (error) => {
console.error('Failed to load accounts', error);
this.errorMessage = 'Unable to load accounts. Please try again.';
}
});
That works for one screen. Across dozens of screens, copying the handler everywhere produces inconsistent messages, forgotten edge cases and duplicated code, which is why reusable patterns matter.
Not all failures mean the same thing
The status code usually tells you who can fix the problem, and that decides how to react.
400 Bad Request: the input is wrong
The frontend sent invalid data.
400 Bad Request
The user can usually fix it, for example by correcting a form field.
401 Unauthorized: the session is missing or expired
Credentials are missing, most often because the access token expired.
401 Unauthorized
The usual reaction is a redirect to sign in, or a token renewal first.
403 Forbidden: authenticated but not allowed
The server knows the user but refuses the action.
403 Forbidden
This is routine with role-based access control. Sending the user to login would not help; they need to hear that they lack permission.
404 Not Found: the resource does not exist
The entity was deleted or the URL is stale.
404 Not Found
500 Internal Server Error: the backend broke
Something failed on the server.
500 Internal Server Error
The user cannot fix this; offer a clear message and a way to try again later.
Network errors: the request never arrived
Sometimes the request never reaches the server at all.
Internet connection lost
Server unavailable
DNS failure
Request timeout
Angular reports these as an HttpErrorResponse with status 0, distinguishing them from real server responses. The category drives everything that follows: whether to retry, redirect, and what to tell the user.
Model the request as a set of states
Tracking only success is a frequent mistake. Treat a request as a small state machine: it starts loading and ends in success or error.
Loading
↓
Success
or
Loading
↓
Error
In a component, that means explicit flags for loading and the error message, updated on every branch.
loading = false;
errorMessage = '';
loadAccounts() {
this.loading = true;
this.errorMessage = '';
this.http.get('/api/accounts').subscribe({
next: (data) => {
this.accounts = data;
this.loading = false;
},
error: () => {
this.errorMessage =
'Unable to load accounts. Please try again.';
this.loading = false;
}
});
}
The crucial detail is resetting loading in the error callback; without it, every failure leaves an endless spinner. Clearing errorMessage at the start also stops a stale error lingering next to fresh data.
Let finalize() clean up the loading flag
Resetting the flag in two places is error-prone. RxJS finalize() gives cleanup a single home.
import { finalize } from 'rxjs';
loadAccounts() {
this.loading = true;
this.http.get('/api/accounts')
.pipe(
finalize(() => {
this.loading = false;
})
)
.subscribe({
next: (data) => {
this.accounts = data;
},
error: () => {
this.errorMessage =
'Unable to load accounts.';
}
});
}
The finalize() callback runs when the observable completes or errors, and also when the subscription is torn down early. That makes it ideal for hiding loading indicators, and the duplicated this.loading = false lines disappear.
Centralize shared behavior in an HTTP interceptor
An expired session or a generic outage should be handled identically on every page, so implementing it per component is repetitive and fragile. An HTTP interceptor sees every HttpClient request, making it the right place for application-wide policy. This one uses the functional HttpInterceptorFn style and branches on status.
export const errorInterceptor: HttpInterceptorFn =
(req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
console.log('Session expired');
}
if (error.status === 403) {
console.log('Access denied');
}
if (error.status >= 500) {
console.log('Server error');
}
return throwError(() => error);
})
);
};
The logging calls are placeholders for a redirect, toast or monitoring event. More importantly, the interceptor ends with throwError(() => error), so the error still reaches the calling component. The interceptor handles the cross-cutting part, the component handles what only it understands, and that separation keeps the code maintainable. Functional interceptors are registered with provideHttpClient(withInterceptors([...])).
Translate technical errors into human messages
Backends do not always return friendly text:
{
"message": "NullPointerException at AccountService.java:142"
}
That helps the service owners, but shown to a customer it confuses and leaks implementation details. Show something written for the user instead.
We couldn't load your accounts right now.
Please try again.
An expired session deserves its own wording:
Your session has expired.
Please sign in again.
So does a permission problem:
You don't have permission to access this resource.
A good message answers three questions:
- What went wrong?
- Does the user need to act?
- What can they do next?
Mapping status codes to messages in one place keeps wording consistent.
Retry transient failures, but only safe ones
Some failures are momentary, like a network hiccup. For those, RxJS offers retry().
this.http.get('/api/accounts')
.pipe(
retry(2)
)
.subscribe({
next: (data) => {
this.accounts = data;
},
error: () => {
this.errorMessage =
'Unable to load accounts.';
}
});
retry(2) resubscribes up to two more times before the error reaches the error callback. Because HttpClient observables are cold, each resubscription sends a new request.
That is also the danger. If a payment request succeeded on the server but its response was lost, a retry can charge the customer twice unless the backend supports idempotency. Be wary of automatic retries for:
- Payments
- Orders
- Account creation
- Data updates
- Delete operations
Read-only GET requests are much easier to reason about. Note too that a bare retry() repeats on every error, including a 400 or 401 that will never succeed; usually you want to retry only network errors and 5xx responses.
Space retries out with a delay
An immediate retry often hits the same overloaded server. The configuration form of retry() adds a pause.
import { retry } from 'rxjs';
this.http.get('/api/accounts')
.pipe(
retry({
count: 2,
delay: 1000
})
)
.subscribe();
The request gets two more chances, one second apart. delay also accepts a function, which lets you implement exponential backoff (each wait longer than the last) and skip retries for errors that cannot succeed. Backoff keeps many failing clients from hammering a recovering server in lockstep.
Treat expired sessions as a global concern
A classic scenario: a user opens the application in the morning and returns hours later. The token has expired, so the next request fails.
GET /api/accounts
401 Unauthorized
The interceptor is the place to detect this:
if (error.status === 401) {
// Clear authentication state
// Redirect to login
}
Handling it in one place means no component reimplements session expiry. With refresh tokens, the interceptor can try renewal before redirecting; when several requests fail at once, see why concurrent 401s log users out for the single-flight approach.
Put a retry button in the user's hands
Often the most effective recovery tool is a plain button that repeats the request.
<div *ngIf="errorMessage">
<p>{{ errorMessage }}</p>
<button (click)="loadAccounts()">
Try Again
</button>
</div>
Picture a user whose connection drops for a few seconds. Once back online, they do not need to reload the application and lose state; they just press:
Try Again
Because loadAccounts() resets its state first, it serves both the initial load and every retry. Angular 17+ templates can use @if instead of *ngIf.
Fall back to stale data when freshness allows it
If a dashboard refresh fails, the last loaded figures plus an honest notice often beat an empty page.
Showing your last available data.
We couldn't refresh the dashboard.
A cache makes this possible, and the notice is essential. Avoid the pattern where freshness is critical: balances, stock prices, inventory and payment details should show an error rather than a stale value.
Log enough context to debug the failure
Hiding details from users does not mean discarding them. A useful log record contains:
Endpoint
HTTP status
Timestamp
Request identifier
User/session context where appropriate
Error category
A minimal interceptor version:
console.error({
endpoint: req.url,
status: error.status,
message: error.message
});
In production nobody watches the console, so send the payload to an error-monitoring service where the request identifier correlates it with backend logs. Never log tokens or unnecessary personal data.
The balance to aim for: the user gets a helpful message, and the developer gets enough detail to find the cause.
Do not hide failures behind empty results
A tempting shortcut is to swallow the error and return an empty value:
catchError(() => {
return of([]);
})
It quietly changes the meaning: an empty array claims the server returned zero records. A user seeing no transactions may conclude they have none, which is worse than an error. Fallbacks can be right, but deliberately; otherwise propagate the error:
catchError((error) => {
return throwError(() => error);
})
Split responsibilities between global and feature-level handling
Global handling suits concerns that mean the same everywhere:
401 → Authentication handling
403 → Authorization handling
500 → Generic server error
Those belong in an interceptor. Now consider:
POST /api/transfer-money
A failed transfer needs a specific explanation and a clear statement of whether money moved, which a generic toast cannot give. The feature must receive the error, which is why the interceptor re-throws. The robust design is global handling for shared concerns plus feature-specific handling where business context matters.
A review checklist for failure paths
Error handling is about designing what happens after something breaks. When reviewing a feature, ask:
- Does the user understand what happened?
- Can they recover without reloading the page?
- Is there a way to retry?
- Does the loading indicator always stop?
- Are technical details kept out of the interface?
- Is the failure logged somewhere developers will see it?
- What happens when the token expires mid-session?
- What happens when the network drops?
- What happens when the backend returns data in an unexpected shape?
Key takeaways
Failures are normal, so aim to fail gracefully. In Angular that combines:
- Explicit loading, success and error states for every request
finalize()so cleanup runs no matter how the request endscatchError()used to control errors, not to hide them- An interceptor for session expiry, permissions and generic server errors, which re-throws so features can still react
- Retries limited to safe, idempotent requests, with delays or backoff
- Messages that tell users what happened and what to do next
- Manual retry buttons and, where freshness permits, clearly labeled fallback data
- Structured logging sent to a monitoring service in production
A dependable application is defined by how it behaves when something goes wrong.