Error Handling and Recovery Patterns

1. catchError for Exception Handling and Recovery

Operator Syntax Description Return Behavior
catchError catchError((err, caught) => obs$) Catches errors and returns recovery observable - prevents stream termination Must return observable (fallback, retry, or EMPTY)
caught argument catchError((err, caught) => caught) Second parameter is source observable - enables restart/retry patterns Return caught to retry from beginning

Example: Error catching and recovery

import { of, throwError, EMPTY } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

// Basic error catching with fallback
of(1, 2, 3, 4, 5).pipe(
  map(n => {
    if (n === 3) throw new Error('Error at 3');
    return n * 10;
  }),
  catchError(err => {
    console.error('Caught:', err.message);
    return of(999); // Fallback value
  })
).subscribe(val => console.log(val)); // 10, 20, 999, completes

// catchError with EMPTY - suppress error, complete silently
throwError(() => new Error('Fail')).pipe(
  catchError(err => {
    console.error('Error suppressed:', err.message);
    return EMPTY; // Complete without emitting
  })
).subscribe({
  next: val => console.log(val),
  complete: () => console.log('Completed')
});

// catchError in HTTP request
this.http.get('/api/data').pipe(
  catchError(err => {
    console.error('API Error:', err);
    return of({ data: [], fromCache: true }); // Fallback data
  })
).subscribe(response => displayData(response.data));

// Multiple catchError for different error types
this.http.get('/api/users').pipe(
  map(response => response.data),
  catchError(err => {
    if (err.status === 404) {
      return of([]); // Empty array for not found
    } else if (err.status === 401) {
      this.router.navigate(['/login']);
      return EMPTY;
    } else {
      return throwError(() => err); // Re-throw other errors
    }
  })
).subscribe(users => displayUsers(users));

// Practical: graceful degradation
const primaryApi$ = this.http.get('/api/primary');
const fallbackApi$ = this.http.get('/api/fallback');

primaryApi$.pipe(
  catchError(primaryErr => {
    console.warn('Primary failed, trying fallback');
    return fallbackApi$.pipe(
      catchError(fallbackErr => {
        console.error('Both failed');
        return of({ error: 'Service unavailable' });
      })
    );
  })
).subscribe(data => processData(data));

// Practical: error with user notification
this.userService.deleteUser(userId).pipe(
  catchError(err => {
    this.notificationService.showError('Failed to delete user');
    this.logger.error('Delete error:', err);
    return EMPTY; // Suppress error after handling
  })
).subscribe(() => this.notificationService.showSuccess('User deleted'));

// Using caught parameter for retry
let retryCount = 0;
const source$ = throwError(() => new Error('Fail'));
source$.pipe(
  catchError((err, caught) => {
    retryCount++;
    if (retryCount < 3) {
      console.log(`Retry ${retryCount}`);
      return caught; // Retry by returning source
    }
    return of('Failed after retries');
  })
).subscribe(val => console.log(val));
Note: catchError must return an Observable. Use EMPTY to complete silently, of(value) for fallback, or throwError to re-throw.

2. retry and retryWhen for Automatic Retry Logic

Operator Syntax Description Strategy
retry retry(count?) Resubscribes to source on error, up to count times (infinite if omitted) Immediate retry, no delay
retry (config) retry({ count, delay, resetOnSuccess }) Configurable retry with delay and reset options Delayed retry with backoff support
retryWhen retryWhen(notifier => notifier.pipe(...)) Custom retry logic based on error notification stream Fully customizable (exponential backoff, conditional)

Example: Retry strategies

import { throwError, timer, of } from 'rxjs';
import { retry, retryWhen, mergeMap, tap, delay } from 'rxjs/operators';

// retry - simple retry N times
let attempt = 0;
const unstable$ = new Observable(subscriber => {
  attempt++;
  if (attempt < 3) {
    subscriber.error(new Error(`Attempt ${attempt} failed`));
  } else {
    subscriber.next('Success!');
    subscriber.complete();
  }
});

unstable$.pipe(
  retry(2) // Retry up to 2 times (3 total attempts)
).subscribe({
  next: val => console.log(val),
  error: err => console.error('Final error:', err)
});

// retry with delay configuration
this.http.get('/api/data').pipe(
  retry({
    count: 3,
    delay: 1000, // Wait 1s between retries
    resetOnSuccess: true
  })
).subscribe(data => processData(data));

// retryWhen - exponential backoff
this.http.get('/api/users').pipe(
  retryWhen(errors => 
    errors.pipe(
      mergeMap((err, index) => {
        const retryAttempt = index + 1;
        if (retryAttempt > 3) {
          return throwError(() => err); // Max retries exceeded
        }
        const delayTime = Math.pow(2, retryAttempt) * 1000; // Exponential backoff
        console.log(`Retry ${retryAttempt} after ${delayTime}ms`);
        return timer(delayTime);
      })
    )
  )
).subscribe(
  users => displayUsers(users),
  err => showError('Failed after retries')
);

// retryWhen - conditional retry based on error type
this.http.post('/api/save', data).pipe(
  retryWhen(errors => 
    errors.pipe(
      mergeMap((err, index) => {
        // Retry only on network errors, not client errors
        if (err.status >= 500 && index < 3) {
          return timer((index + 1) * 2000); // 2s, 4s, 6s
        }
        return throwError(() => err); // Don't retry client errors
      })
    )
  )
).subscribe(
  response => handleSuccess(response),
  err => handleError(err)
);

// Practical: retry with max delay cap
const maxDelay = 10000; // Cap at 10 seconds
apiCall$.pipe(
  retryWhen(errors => 
    errors.pipe(
      mergeMap((err, index) => {
        if (index >= 5) return throwError(() => err);
        const backoff = Math.min(Math.pow(2, index) * 1000, maxDelay);
        return timer(backoff);
      })
    )
  )
).subscribe(data => processData(data));

// Practical: retry with user notification
this.http.get('/api/critical-data').pipe(
  retryWhen(errors => 
    errors.pipe(
      tap(() => this.notificationService.show('Retrying...')),
      delay(2000),
      take(3),
      concat(throwError(() => new Error('Max retries reached')))
    )
  ),
  catchError(err => {
    this.notificationService.showError('Unable to load data');
    return of(null);
  })
).subscribe(data => {
  if (data) {
    this.notificationService.showSuccess('Data loaded');
    processData(data);
  }
});

// Practical: smart retry - wait for connection
const checkOnline$ = interval(5000).pipe(
  filter(() => navigator.onLine),
  take(1)
);

apiCall$.pipe(
  retryWhen(errors => 
    errors.pipe(
      switchMap(err => {
        if (!navigator.onLine) {
          console.log('Waiting for connection...');
          return checkOnline$;
        }
        return timer(2000);
      }),
      take(5)
    )
  )
).subscribe(data => syncData(data));

3. throwError for Error Emission

Function Syntax Description Use Case
throwError throwError(() => error) Creates observable that immediately emits error notification Testing, error propagation, fallback errors
Factory function throwError(() => new Error(msg)) Factory ensures fresh error instance per subscription Prevents shared error object mutations

Example: Error emission patterns

import { throwError, of, iif } from 'rxjs';
import { catchError, mergeMap } from 'rxjs/operators';

// Basic throwError
throwError(() => new Error('Something went wrong')).subscribe({
  next: val => console.log(val),
  error: err => console.error('Error:', err.message)
});

// Conditional error throwing
const isValid = false;
iif(
  () => isValid,
  of('Valid data'),
  throwError(() => new Error('Validation failed'))
).subscribe({
  next: val => console.log(val),
  error: err => console.error(err.message)
});

// throwError in catchError chain
this.http.get('/api/data').pipe(
  mergeMap(response => {
    if (!response.data) {
      return throwError(() => new Error('No data in response'));
    }
    return of(response.data);
  }),
  catchError(err => {
    this.logger.error(err);
    return throwError(() => new Error('Data processing failed'));
  })
).subscribe({
  next: data => processData(data),
  error: err => showErrorMessage(err.message)
});

// Practical: validation with throwError
function validateUser(user) {
  if (!user.email) {
    return throwError(() => new Error('Email required'));
  }
  if (!user.name) {
    return throwError(() => new Error('Name required'));
  }
  return of(user);
}

const user = { name: 'Alice' };
validateUser(user).subscribe({
  next: validUser => saveUser(validUser),
  error: err => showValidationError(err.message)
});

// Practical: custom error types
class ValidationError extends Error {
  constructor(public field: string, message: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

class NetworkError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
    this.name = 'NetworkError';
  }
}

this.http.get('/api/users').pipe(
  catchError(err => {
    if (err.status === 0) {
      return throwError(() => new NetworkError(0, 'No internet connection'));
    }
    return throwError(() => new NetworkError(err.status, err.message));
  })
).subscribe({
  error: err => {
    if (err instanceof NetworkError) {
      handleNetworkError(err);
    } else {
      handleGenericError(err);
    }
  }
});

// Practical: error transformation
apiCall$.pipe(
  catchError(err => 
    throwError(() => ({
      originalError: err,
      timestamp: Date.now(),
      context: 'API Call Failed',
      userMessage: 'Please try again later'
    }))
  )
).subscribe({
  error: errorInfo => {
    logError(errorInfo);
    showMessage(errorInfo.userMessage);
  }
});

4. onErrorResumeNext for Alternative Stream Switching

Operator Syntax Description Behavior
onErrorResumeNext onErrorResumeNext(obs1$, obs2$, ...) Continues with next observable on error OR completion, ignoring errors Errors are swallowed, continues to next stream

Example: Alternative stream switching

import { onErrorResumeNext, of, throwError } from 'rxjs';
import { map } from 'rxjs/operators';

// onErrorResumeNext - continue despite errors
const first$ = throwError(() => new Error('First failed'));
const second$ = of(2, 3, 4);
const third$ = throwError(() => new Error('Third failed'));
const fourth$ = of(5, 6);

onErrorResumeNext(first$, second$, third$, fourth$).subscribe({
  next: val => console.log(val), // 2, 3, 4, 5, 6
  error: err => console.error('Error:', err), // Never called
  complete: () => console.log('Complete')
});

// Compare with catchError
// catchError - handles error and decides next action
of(1, 2, 3).pipe(
  map(n => {
    if (n === 2) throw new Error('Error at 2');
    return n;
  }),
  catchError(err => of(999))
).subscribe(val => console.log(val)); // 1, 999

// onErrorResumeNext - silently moves to next observable
onErrorResumeNext(
  of(1, 2, 3).pipe(
    map(n => {
      if (n === 2) throw new Error('Error');
      return n;
    })
  ),
  of(4, 5, 6)
).subscribe(val => console.log(val)); // 1, 4, 5, 6 (error silently skipped)

// Practical: try multiple data sources
const cache$ = this.cacheService.get('data').pipe(
  map(data => {
    if (!data) throw new Error('No cache');
    return data;
  })
);
const api$ = this.http.get('/api/data');
const fallback$ = of({ data: [], offline: true });

onErrorResumeNext(cache$, api$, fallback$).subscribe(
  data => displayData(data)
);

// Practical: best-effort data collection
const source1$ = this.http.get('/api/source1').pipe(
  catchError(() => EMPTY)
);
const source2$ = this.http.get('/api/source2').pipe(
  catchError(() => EMPTY)
);
const source3$ = this.http.get('/api/source3').pipe(
  catchError(() => EMPTY)
);

onErrorResumeNext(source1$, source2$, source3$).pipe(
  scan((acc, data) => [...acc, data], [])
).subscribe(allData => processCollectedData(allData));

// Note: Prefer catchError for error handling
// onErrorResumeNext silently swallows errors which can hide issues
Warning: onErrorResumeNext silently swallows errors. Prefer catchError for explicit error handling and recovery logic.

5. timeout and timeoutWith for Time-based Error Handling

Operator Syntax Description On Timeout
timeout timeout(duration) Errors if no emission within specified time (ms) Emits TimeoutError
timeout (config) timeout({ first, each, with }) Configurable timeout for first emission and each subsequent Error or switch to 'with' observable
timeoutWith timeoutWith(duration, fallback$) Switches to fallback observable on timeout (deprecated, use timeout config) Switch to fallback stream

Example: Timeout handling

import { of, throwError, timer } from 'rxjs';
import { timeout, delay, catchError } from 'rxjs/operators';

// Basic timeout
of('data').pipe(
  delay(3000),
  timeout(2000) // Timeout after 2 seconds
).subscribe({
  next: val => console.log(val),
  error: err => console.error('Timeout:', err.name) // TimeoutError
});

// timeout with different limits
const slow$ = timer(5000);
slow$.pipe(
  timeout({
    first: 3000, // First value must arrive within 3s
    each: 1000   // Subsequent values within 1s of previous
  })
).subscribe({
  error: err => console.error('Timeout error')
});

// timeout with fallback observable
of('slow data').pipe(
  delay(5000),
  timeout({
    first: 2000,
    with: () => of('fallback data') // Switch to this on timeout
  })
).subscribe(val => console.log(val)); // 'fallback data'

// Practical: API call timeout
this.http.get('/api/users').pipe(
  timeout(5000), // 5 second timeout
  catchError(err => {
    if (err.name === 'TimeoutError') {
      this.notificationService.showError('Request timed out');
      return of([]);
    }
    return throwError(() => err);
  })
).subscribe(users => displayUsers(users));

// Practical: timeout with retry
this.http.post('/api/save', data).pipe(
  timeout(10000),
  retry({
    count: 2,
    delay: (error) => {
      if (error.name === 'TimeoutError') {
        return timer(1000); // Retry after 1s on timeout
      }
      return throwError(() => error);
    }
  }),
  catchError(err => {
    if (err.name === 'TimeoutError') {
      return of({ error: 'Operation timed out after retries' });
    }
    return throwError(() => err);
  })
).subscribe(response => handleResponse(response));

// Practical: real-time data with timeout fallback
const liveData$ = this.websocket.messages$.pipe(
  timeout({
    first: 5000,
    each: 3000,
    with: () => this.http.get('/api/fallback-data') // Use REST as fallback
  })
);

liveData$.subscribe(
  data => updateUI(data),
  err => console.error('Stream error:', err)
);

// Practical: user interaction timeout
const userInput$ = fromEvent(input, 'input');
userInput$.pipe(
  timeout({
    first: 30000, // User must start typing within 30s
    with: () => {
      showMessage('Session expired due to inactivity');
      return EMPTY;
    }
  })
).subscribe(event => processInput(event));

// Practical: progressive timeout
const progressiveTimeout$ = this.http.get('/api/large-dataset').pipe(
  timeout({
    first: 10000,  // 10s for first response
    each: 5000,    // 5s between chunks
    with: () => {
      // Try faster endpoint
      return this.http.get('/api/large-dataset/summary').pipe(
        map(summary => ({ partial: true, data: summary }))
      );
    }
  })
);

progressiveTimeout$.subscribe(
  data => {
    if (data.partial) {
      showPartialData(data.data);
    } else {
      showFullData(data);
    }
  }
);

6. finalize for Cleanup Operations

Operator Syntax Description Execution
finalize finalize(() => cleanup()) Executes callback when observable completes, errors, or unsubscribes Always runs on stream termination (success/error/cancel)

Example: Cleanup operations with finalize

import { of, throwError, interval } from 'rxjs';
import { finalize, take, delay } from 'rxjs/operators';

// finalize on completion
of(1, 2, 3).pipe(
  finalize(() => console.log('Cleanup on complete'))
).subscribe(val => console.log(val));
// Output: 1, 2, 3, 'Cleanup on complete'

// finalize on error
throwError(() => new Error('Fail')).pipe(
  finalize(() => console.log('Cleanup on error'))
).subscribe({
  error: err => console.error(err.message)
});
// Output: 'Fail', 'Cleanup on error'

// finalize on unsubscribe
const subscription = interval(1000).pipe(
  finalize(() => console.log('Cleanup on unsubscribe'))
).subscribe(val => console.log(val));

setTimeout(() => subscription.unsubscribe(), 3500);
// Output: 0, 1, 2, 3, 'Cleanup on unsubscribe'

// Practical: loading spinner
this.http.get('/api/data').pipe(
  tap(() => this.showSpinner()),
  finalize(() => this.hideSpinner()) // Always hide spinner
).subscribe(
  data => this.displayData(data),
  err => this.showError(err)
);

// Practical: resource cleanup
const connection$ = this.openWebSocket().pipe(
  finalize(() => {
    console.log('Closing WebSocket connection');
    this.closeWebSocket();
  })
);

connection$.subscribe(
  message => handleMessage(message),
  err => handleError(err)
);

// Practical: temporary UI state
fromEvent(button, 'click').pipe(
  tap(() => button.disabled = true),
  switchMap(() => this.saveData()),
  finalize(() => button.disabled = false) // Re-enable button
).subscribe(
  result => showSuccess(result),
  err => showError(err)
);

// Practical: analytics tracking
this.processPayment(paymentData).pipe(
  finalize(() => {
    this.analytics.track('payment_attempt_completed', {
      success: this.paymentSuccess,
      timestamp: Date.now()
    });
  })
).subscribe(
  result => {
    this.paymentSuccess = true;
    showConfirmation(result);
  },
  err => {
    this.paymentSuccess = false;
    showError(err);
  }
);

// Practical: file upload cleanup
this.uploadFile(file).pipe(
  tap(() => this.uploadProgress = 0),
  finalize(() => {
    this.uploadProgress = null;
    this.cleanupTempFiles();
    console.log('Upload process completed');
  })
).subscribe(
  response => handleUploadSuccess(response),
  err => handleUploadError(err)
);

// Multiple finalize operators
of(1, 2, 3).pipe(
  finalize(() => console.log('First cleanup')),
  map(x => x * 10),
  finalize(() => console.log('Second cleanup'))
).subscribe(val => console.log(val));
// Output: 10, 20, 30, 'Second cleanup', 'First cleanup' (reverse order)

// Practical: database transaction
this.database.beginTransaction().pipe(
  switchMap(tx => this.performOperations(tx)),
  finalize(() => this.database.commit()) // Always commit/rollback
).subscribe(
  result => console.log('Transaction completed'),
  err => {
    this.database.rollback();
    console.error('Transaction failed');
  }
);
Note: finalize is guaranteed to run on completion, error, or unsubscribe - perfect for cleanup like hiding loaders, closing connections, or releasing resources.

Section 6 Summary

  • catchError handles errors and returns recovery observable (fallback, EMPTY, or retry)
  • retry resubscribes on error, retryWhen enables custom retry strategies (exponential backoff)
  • throwError creates error-emitting observable for testing and error propagation
  • timeout errors if no emission within time limit, can provide fallback observable
  • finalize executes cleanup on complete/error/unsubscribe - always runs
  • Error handling pattern: try → retry → catchError → finalize for robust pipelines