You've encountered those "fun" stories where a developer finishes a task, it passes testing, goes to production, and
then meets an unexpected failure of some minor API method, bringing down the entire app so users see only a blank
screen?
I got too close to them back in the day... Honestly, RxJS streams are excellent teachers — you won’t want to repeat
their lessons. What do they teach us? First and foremost: don’t trust external sources. You control neither the server
connection nor the API service, so you have no grounds to blindly trust them or expect flawless operation.
If your backend has five-nines availability (an excellent result!), it’s still down for a few minutes a year. Failures
happen to all systems.
Typical Newbie Scenarios
- Ghost Data
this.api.getData().subscribe(
data => this.render(data) // What if data === undefined?
);
- Silent Crash
combineLatest(
loadUsers(),
loadProducts() // If this fails — everything stops
).subscribe();
- Domino Effect
interval(1000).pipe(
switchMap(() => new Observable(o => {
o.error('Error!')
}))
).subscribe(); // On error, the entire stream crashes, data stops updating
“A good developer writes code. A great one anticipates how it will break.”
— Unknown Architect*
Basic Error Handling Operators: Your "First Aid Kit" (Relevant for RxJS 7.8+)
catchError
: The Digital First Aid
How It Works
Imagine your Observable is a delivery service. catchError
is the insurance company — yes, it can’t return a lost
package, but it will try to offer a replacement (would a monetary refund work for you?).
Practice
import {catchError} from 'rxjs/operators';
import {of} from 'rxjs';
const request = new Observable(o => {
o.error('Error!');
o.complete();
});
request.pipe(
catchError(error => {
console.log(error); // Log the issue
return of([]); // Return empty array as fallback
})
).subscribe(orders => {
console.log(orders); // Always get data to display
});
In all examples, I'll use custom
Observable
like this. Let’s agree that in real projects you’d use something like
this.http.get('/api/orders')
. This format allows you to copy-paste code for experiments, while HTTP examples would
require rewriting. Similarly, I useconsole.log(error)
instead ofthis.logService.report(error)
for simplicity.
retry: Smart Retry with Control
Philosophy
Like a good barista remakes coffee after a mistake — retry
repeats requests. But remember: not all operations are
idempotent!
Configuration Example
import {retry, timer} from 'rxjs';
const request = new Observable(o => {
o.error('Error!');
o.complete();
});
request.pipe(
retry({
count: 3, // Max 3 attempts
delay: (error, retryCount) => timer(1000 * retryCount) // Growing delay: 1s, 2s, 3s
})
).subscribe();
Safety Rules
- Never use for POST/PUT requests
- Always set a reasonable attempt limit
- Combine with delays to protect the server
finalize: Guaranteed Cleanup
Why It Matters
No matter the battle, cleanup must happen. finalize
executes regardless of outcome:
- Success
- Error
- Manual unsubscription
Ideal Use Case
this.loading = true;
const request = new Observable(o => {
o.error('Error!');
o.complete();
});
request.pipe(
finalize(() => {
this.loading = false; // Always reset the flag
console.log('DataLoadCompleted');
})
).subscribe();
Why We No Longer Use retryWhen?
-
Deprecated Approach
retryWhen
is marked deprecated in RxJS 7.8+. -
New Capabilities
The
retry
config object is simpler and safer:
retry({
count: 4,
delay: (_, i) => timer(1000 * 2 ** i) // Exponential backoff
})
- Code Readability Config objects make retry logic explicit.
Battle-Hardened Tips
-
Three-Layer Rule
- Layer 1: Request retry (
retry
) - Layer 2: Fallback data (
catchError
) - Layer 3: Global error handler
- Layer 1: Request retry (
80/20 Rule
Handle 80% of errors viacatchError
, 20% via complex strategies.-
Logging as a Diary
Always record:- Error type
- Operation context
- Timestamp
Test Failure Scenarios
Add 1-2 error tests for every ten positive tests.
"Code without error handling is like a house with no fire exit: works until disaster strikes."
Operator Use Cases: From Theory to Practice
Scenario 1: Graceful Data Degradation
Problem
The app crashes if the API returns 404 on a product page.
Solution with catchError
this.product$ = this.http.get(`/api/products/${id}`).pipe(
catchError(error => {
if (error.status === 404) {
return of({
id,
name: 'Product temporarily unavailable',
image: '/assets/placeholder.jpg'
});
}
throw error; // Propagate other errors
})
);
Effect:
Users see an informative card instead of a blank screen.
Scenario 2: Smart Request Retries
Problem
Mobile clients often lose connection while loading news feeds.
Solution with retry
this.http.get('/api/feed').pipe(
retry({
count: 3, // Max 3 attempts
delay: (error, retryCount) => timer(1000 * retryCount) // Linear delay
}),
catchError(err => {
this.offlineService.showWarning();
return EMPTY;
})
).subscribe();
Stats:
Reduced feed loading errors in unstable network conditions.
Scenario 3: Complex Payment Processing
Problem
Need to guarantee payment clearing even during errors.
Operator Combination
processPayment(paymentData).pipe(
retry(2), // Retry for temporary failures
catchError(error => {
this.fallbackProcessor.process(paymentData);
return EMPTY;
}),
finalize(() => {
this.cleanupResources();
this.logService.flush(); // Guaranteed log write
})
).subscribe();
Architecture Tip:
Always separate "retryable" and "fatal" errors.
Scenario 4: Background Sync
Problem
Background sync process "hangs" on errors.
Solution with finalize
this.syncJob = interval(30_000).pipe(
switchMap(() => this.syncService.run()),
finalize(() => {
this.jobRegistry.unregister('background-sync');
this.memoryCache.clear();
})
).subscribe();
Key Point:
Resources are released even with manual unsubscription.
Battlefield Tips
- "Layered Defense" Pattern Combine operators as filters:
Stream → retry(3) → catchError → finalize
- Metrics Are Your Friends Add error counters:
catchError(error => {
this.metrics.increment('API_ERRORS');
throw error;
})
- Test Edge Cases Use marble diagrams to simulate errors:
Error Handling Strategies: How to Avoid Drowning in Exceptions
1. "Safe Islands" Strategy
Concept
Break the stream into independent segments with local error handling. Like watertight compartments in a ship.
Implementation
merge(
this.loadUserData().pipe(
catchError(() => of(null)) // Errors won't break other streams
),
this.loadProducts().pipe(
retry(2) // Custom retry policy
)
).pipe(
finalize(() => this.hideLoader()) // Shared cleanup point
);
Effect:
Errors in one stream don’t crash the entire system.
2. "Layered Defense" Strategy
Three-Tier Model
- Request Level: Retries for temporary failures
this.http.get(...).pipe(retry(3))
- Component Level: Fallback data
catchError(() => this.cache.getData())
- Application Level: Global error interceptor
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError(error) {
this.sentry.captureException(error);
}
}
3. "Smart Retry" Strategy
When to Use
- Services with unstable connections
- Mission-critical operations
- Background syncs
Exponential Backoff Pattern
retry({
count: 4,
delay: (error, retryCount) => timer(1000 * 2 ** retryCount)
})
Real-World Stats:
Successful recovery in most cases with 4 attempts.
4. "Silent Fail" Strategy
Purpose
- Non-data-critical components
- Demo modes
- Functional degradation scenarios (e.g., switching from streams to HTTP requests)
Implementation
this.liveUpdates$ = websocketStream.pipe(
catchError(() => interval(5000).pipe(
switchMap(() => this.http.get('/polling-endpoint'))
)
)
);
Effect:
Users continue working in a limited mode.
5. "Explicit Failure" Strategy
When to Use
- Financial operations
- Legally binding actions
- Security systems
Implementation
processTransaction().pipe(
tap({error: () => this.rollbackTransaction()}),
catchError(error => {
this.showFatalError();
throw error;
})
);
Golden Rule:
Better to fail explicitly than enter an invalid state.
Strategy Selection Checklist
- How critical is the operation?
- Money/security → "Explicit Failure"
- Data viewing → "Silent Fail"
- How frequent are errors?
- Often → "Layered Defense"
- Rare → "Safe Islands"
- What resources are available?
- Cache exists → "Smart Retry"
- No fallbacks → "Silent Fail"
"Strategy without metrics is like a compass without a needle."
— Observability Principle in Microservices
Common Beginner Mistakes: How to Avoid Pitfalls
1. Silent Error Swallowing
❌ Problematic Approach
this.http.get('/api/data').subscribe(data => {
// Errors? What errors?
this.render(data);
});
Consequences:
Users see a frozen UI, errors go unlogged.
✅ Correct Solution
this.http.get('/api/data').subscribe({
next: data => this.render(data),
error: err => this.handleError(err) // Always handle errors
});
2. Infinite Retry Loops
❌ Dangerous Code
this.http.get('/api/orders').pipe(
retry() // Infinite loop on 500 errors
);
Risks:
Self-inflicted DDoS, mobile battery drain.
✅ Safe Approach
retry(3) // Clear attempt limit
3. Ignoring Unsubscriptions
❌ Common Pitfall
ngOnInit()
{
interval(1000).subscribe(data => {
// Subscription lives forever after route changes
this.updateRealTimeData(data);
});
}
Effect:
Memory leaks, update conflicts.
✅ Professional Approach
private
destroy$ = new Subject<void>();
ngOnInit()
{
interval(1000).pipe(
takeUntil(this.destroy$)
).subscribe(...);
}
ngOnDestroy()
{
this.destroy$.next();
this.destroy$.complete();
}
4. Global Handler as a "Trash Can"
❌ Anti-Pattern
// global-error-handler.ts
handleError(error)
{
// Catch ALL errors indiscriminately
this.sentry.captureException(error);
}
Issue:
No way to customize handling for specific scenarios.
✅ Stratified Approach
// Local handling
catchError(err => handleLocalError(err))
// Global handler
handleError(error)
{
if (error.isCritical) {
this.sentry.captureException(error);
}
}
Self-Check Checklist
- Do all subscriptions have
error
handling? - Are there
retry
attempt limits? - Is
takeUntil
used for unsubscriptions? - Are global and local errors separated?
"Errors are like rakes: to stop stepping on them, you must first see them in the code."
— Another "auf" principle from dev memes
Conclusion: Errors as a Path to Mastery
What Have We Learned?
Through years of working with RxJS, I've realized: true mastery begins where others see problems. Error handling
isn’t routine—it’s the art of designing resilient systems.
Key Lessons:
Error Handling is a Code Maturity Indicator
EverycatchError
in your code is a step toward professional-grade development.Retries ≠ Panacea
Properly configuredretry
saves where naive implementations harm.
"The best error is the one that never happens. The second best—the one handled gracefully."
— Ancient Developer Proverb
Where to Go Next?
- Experiment with Combinations Example of an advanced chain:
data$.pipe(
retryWhen(exponentialBackoff(1000, 3)),
catchError(switchToCache),
finalize(cleanup)
)
Study Real-World Cases
Explore source code of Angular HttpClient and Ngrx — treasure troves of patterns.Share Knowledge
Write a post about your toughest error — the best way to solidify experience.
"In 20 years of development, I’ve seen many 'perfect' systems. They all broke. The ones that survived treated errors
as part of the design."
— Someone, definitely, once said to someone*
Your Next Step:
Open your latest project. Find at least one stream without error handling — and turn it into a reliability example.
Remember: every handled failure saves hours of support and thousands of satisfied users.
Top comments (0)