DEV Community

Cover image for Step Five into RxJS: Error Handling
Art Stesh
Art Stesh

Posted on

Step Five into RxJS: Error Handling

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

  1. Ghost Data
this.api.getData().subscribe(
    data => this.render(data) // What if data === undefined?
);
Enter fullscreen mode Exit fullscreen mode
  1. Silent Crash
combineLatest(
    loadUsers(),
    loadProducts() // If this fails — everything stops
).subscribe();
Enter fullscreen mode Exit fullscreen mode
  1. Domino Effect
interval(1000).pipe(
    switchMap(() => new Observable(o => {
        o.error('Error!')
    }))
).subscribe(); // On error, the entire stream crashes, data stops updating
Enter fullscreen mode Exit fullscreen mode

“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
});
Enter fullscreen mode Exit fullscreen mode

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 use console.log(error) instead of this.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();
Enter fullscreen mode Exit fullscreen mode

Safety Rules

  1. Never use for POST/PUT requests
  2. Always set a reasonable attempt limit
  3. 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();
Enter fullscreen mode Exit fullscreen mode

Why We No Longer Use retryWhen?

  1. Deprecated Approach retryWhen is marked deprecated in RxJS 7.8+.
  2. New Capabilities The retry config object is simpler and safer:
retry({
    count: 4,
    delay: (_, i) => timer(1000 * 2 ** i) // Exponential backoff
})
Enter fullscreen mode Exit fullscreen mode
  1. Code Readability Config objects make retry logic explicit.

Battle-Hardened Tips

  1. Three-Layer Rule

    • Layer 1: Request retry (retry)
    • Layer 2: Fallback data (catchError)
    • Layer 3: Global error handler
  2. 80/20 Rule

    Handle 80% of errors via catchError, 20% via complex strategies.

  3. Logging as a Diary

    Always record:

    • Error type
    • Operation context
    • Timestamp
  4. 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
    })
);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Key Point:

Resources are released even with manual unsubscription.


Battlefield Tips

  1. "Layered Defense" Pattern Combine operators as filters:
Stream → retry(3) → catchError → finalize
Enter fullscreen mode Exit fullscreen mode
  1. Metrics Are Your Friends Add error counters:
catchError(error => {
    this.metrics.increment('API_ERRORS');
    throw error;
})
Enter fullscreen mode Exit fullscreen mode
  1. 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
);
Enter fullscreen mode Exit fullscreen mode

Effect:

Errors in one stream don’t crash the entire system.


2. "Layered Defense" Strategy

Three-Tier Model

  1. Request Level: Retries for temporary failures
this.http.get(...).pipe(retry(3))
Enter fullscreen mode Exit fullscreen mode
  1. Component Level: Fallback data
catchError(() => this.cache.getData())
Enter fullscreen mode Exit fullscreen mode
  1. Application Level: Global error interceptor

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
    handleError(error) {
        this.sentry.captureException(error);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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'))
        )
    )
);
Enter fullscreen mode Exit fullscreen mode

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;
    })
);
Enter fullscreen mode Exit fullscreen mode

Golden Rule:
Better to fail explicitly than enter an invalid state.


Strategy Selection Checklist

  1. How critical is the operation?
  • Money/security → "Explicit Failure"
  • Data viewing → "Silent Fail"
  1. How frequent are errors?
  • Often → "Layered Defense"
  • Rare → "Safe Islands"
  1. 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);
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

2. Infinite Retry Loops

❌ Dangerous Code

this.http.get('/api/orders').pipe(
    retry() // Infinite loop on 500 errors
);
Enter fullscreen mode Exit fullscreen mode

Risks:
Self-inflicted DDoS, mobile battery drain.

✅ Safe Approach

retry(3) // Clear attempt limit
Enter fullscreen mode Exit fullscreen mode

3. Ignoring Unsubscriptions

❌ Common Pitfall

ngOnInit()
{
    interval(1000).subscribe(data => {
// Subscription lives forever after route changes
        this.updateRealTimeData(data);
    });
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

4. Global Handler as a "Trash Can"

❌ Anti-Pattern

// global-error-handler.ts
handleError(error)
{
// Catch ALL errors indiscriminately
    this.sentry.captureException(error);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Self-Check Checklist

  1. Do all subscriptions have error handling?
  2. Are there retry attempt limits?
  3. Is takeUntil used for unsubscriptions?
  4. 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:

  1. Error Handling is a Code Maturity Indicator

    Every catchError in your code is a step toward professional-grade development.

  2. Retries ≠ Panacea

    Properly configured retry 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?

  1. Experiment with Combinations Example of an advanced chain:
   data$.pipe(
   retryWhen(exponentialBackoff(1000, 3)),
   catchError(switchToCache),
   finalize(cleanup)
   )
Enter fullscreen mode Exit fullscreen mode
  1. Study Real-World Cases

    Explore source code of Angular HttpClient and Ngrx — treasure troves of patterns.

  2. 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)