Skip to content

Common Debugging Scenarios

Typical problems encountered in RxJS development and their solutions are described with concrete code examples.

Scenario 1: Values do not flow

  • Symptom: I subscribe and not a single value is output.

Cause 1: You forgot to subscribe to Cold Observable.

Cold Observable will not run until it is subscribed to.

ts
import { interval } from 'rxjs';
import { map } from 'rxjs';

// ❌ Nothing runs because it's not subscribed to
const numbers$ = interval(1000).pipe(
  map(x => {
    console.log('This line is not executed');
    return x * 2;
  })
);

// ✅ Executed by subscribing
numbers$.subscribe(value => console.log('Value:', value));

Cause 2: Completed Subject

Once a Subject is completed, it will not receive values in subsequent subscriptions.

ts
import { Subject } from 'rxjs';

const subject = new Subject<number>();

subject.complete(); // Complete

// ❌ Subscription after completion does not receive value
subject.subscribe(value => console.log('This line is not executed'));

// ✅ Subscribe before completion
const subject2 = new Subject<number>();
subject2.subscribe(value => console.log('Value:', value));
subject2.next(1); // Value: 1
subject2.complete();

Cause 3: Filtering on wrong conditions

Filtering conditions may be too strict and exclude all values.

ts
import { of } from 'rxjs';
import { filter, tap } from 'rxjs';

of(1, 2, 3, 4, 5)
  .pipe(
    tap(value => console.log('Before filter:', value)),
    filter(x => x > 10), // All excluded
    tap(value => console.log('After filter:', value)) // This line is not executed
  )
  .subscribe({
    next: value => console.log('Final value:', value),
    complete: () => console.log('Complete (no value)')
  });

// Output:
// Before filter: 1
// Before filter: 2
// Before filter: 3
// Before filter: 4
// Before filter: 5
// Complete (no value)

Debugging Techniques

Use the tap operator to see which values are flowing at each step.

ts
import { of, EMPTY } from 'rxjs';
import { filter, tap, defaultIfEmpty } from 'rxjs';

of(1, 2, 3, 4, 5)
  .pipe(
    tap(value => console.log('🔵 Input:', value)),
    filter(x => x > 10),
    tap(value => console.log('🟢 Passed filter:', value)),
    defaultIfEmpty('No value') // Default if there is no value
  )
  .subscribe(value => console.log('✅ Output:', value));

// Output:
// 🔵 Input: 1
// 🔵 Input: 2
// 🔵 Input: 3
// 🔵 Input: 4
// 🔵 Input: 5
// ✅ Output: No value

Scenario 2: Different value is output than expected

  • Symptom: Different value than expected is output.

Cause 1: Operator is in the wrong order.

The result depends on the order in which the operators are applied.

ts
import { of } from 'rxjs';
import { map, filter } from 'rxjs';

// ❌ Different result than expected
of(1, 2, 3, 4, 5)
  .pipe(
    map(x => x * 2),     // 2, 4, 6, 8, 10
    filter(x => x < 5)   // Only 2, 4 pass through
  )
  .subscribe(value => console.log('Result:', value));
// Output: 2, 4

// ✅ Correct order
of(1, 2, 3, 4, 5)
  .pipe(
    filter(x => x < 5),  // Only 1, 2, 3, 4 pass through
    map(x => x * 2)      // 2, 4, 6, 8
  )
  .subscribe(value => console.log('Result:', value));
// Output: 2, 4, 6, 8

Cause 2: Unintended changes due to shared references

Because JavaScript objects are passed by reference, it is possible to modify the original object.

ts
import { of } from 'rxjs';
import { map } from 'rxjs';

interface User {
  id: number;
  name: string;
}

const user: User = { id: 1, name: 'Alice' };

of(user)
  .pipe(
    // ❌ Modifies the original object directly
    map(u => {
      u.name = 'Bob'; // Original object is modified
      return u;
    })
  )
  .subscribe(value => console.log('After change:', value));

console.log('Original object:', user); // { id: 1, name: 'Bob' }

// ✅ Create a new object
of(user)
  .pipe(
    map(u => ({ ...u, name: 'Charlie' })) // New object with spread syntax
  )
  .subscribe(value => console.log('After change:', value));

console.log('Original object:', user); // { id: 1, name: 'Alice' } (not modified)

Cause 3: Timing of asynchronous processing

The order of completion of asynchronous processing may be different than expected.

ts
import { of, delay } from 'rxjs';
import { mergeMap, tap } from 'rxjs';

// ❌ Does not wait for asynchronous processing to complete
of(1, 2, 3)
  .pipe(
    tap(value => console.log('Start:', value)),
    mergeMap(value =>
      of(value * 2).pipe(
        delay(100 - value * 10) // Larger values complete faster
      )
    )
  )
  .subscribe(value => console.log('Complete:', value));

// Output:
// Start: 1
// Start: 2
// Start: 3
// Complete: 3  ← Shortest delay
// Complete: 2
// Complete: 1  ← Longest delay

// ✅ Guarantee order
import { concatMap } from 'rxjs';

of(1, 2, 3)
  .pipe(
    tap(value => console.log('Start:', value)),
    concatMap(value =>  // mergeMap → concatMap
      of(value * 2).pipe(delay(100 - value * 10))
    )
  )
  .subscribe(value => console.log('Complete:', value));

// Output:
// Start: 1
// Complete: 1
// Start: 2
// Complete: 2
// Start: 3
// Complete: 3

Scenario 3: Subscription not completed (infinite stream)

  • Symptom: complete is not called and the stream is not terminated

You need to explicitly complete it, since interval, fromEvent, etc. keep issuing values indefinitely.

ts
import { interval } from 'rxjs';
import { tap } from 'rxjs';

// ❌ interval continues to issue values indefinitely
interval(1000)
  .pipe(
    tap(value => console.log('Value:', value))
  )
  .subscribe({
    complete: () => console.log('This line is not executed')
  });

// ✅ Explicitly complete with take
import { take } from 'rxjs';

interval(1000)
  .pipe(
    take(5), // Complete after 5 values
    tap(value => console.log('Value:', value))
  )
  .subscribe({
    complete: () => console.log('Complete')
  });

Debugging Techniques

Set a timeout to stop the infinite stream when debugging.

ts
import { interval, timer } from 'rxjs';
import { tap, takeUntil } from 'rxjs';

// Set timeout for debugging
const stop$ = timer(5000); // Complete after 5 seconds

interval(1000)
  .pipe(
    takeUntil(stop$),
    tap({
      next: value => console.log('Value:', value),
      complete: () => console.log('Stopped on timeout')
    })
  )
  .subscribe();

Scenario 4: Memory leak (forgot to unsubscribe)

  • Symptom: Application gradually becomes slower and slower

Cause: Unsubscribed subscriptions that are no longer needed

A memory leak occurs when a subscription remains after a component or service is destroyed.

ts
import { interval } from 'rxjs';

class UserComponent {
  private subscription: any;

  ngOnInit() {
    // ❌ Forgot to unsubscribe
    interval(1000).subscribe(value => {
      console.log('Value:', value); // Continues to execute after component is destroyed
    });
  }

  ngOnDestroy() {
    // No unsubscription
  }
}

// ✅ Manage subscriptions properly
class UserComponentFixed {
  private subscription: any;

  ngOnInit() {
    this.subscription = interval(1000).subscribe(value => {
      console.log('Value:', value);
    });
  }

  ngOnDestroy() {
    // Unsubscribe when component is destroyed
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
  }
}

Recommended pattern: use takeUntil.

The takeUntil pattern can be used to automate unsubscriptions.

ts
import { interval, Subject } from 'rxjs';
import { takeUntil } from 'rxjs';

class UserComponentBest {
  private destroy$ = new Subject<void>();

  ngOnInit() {
    // ✅ Automatically unsubscribe with takeUntil
    interval(1000)
      .pipe(
        takeUntil(this.destroy$)
      )
      .subscribe(value => console.log('Value:', value));
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Memory leak detection

Track the number of subscriptions with a custom operator.

ts
import { interval } from 'rxjs';
import { tap } from 'rxjs';

let subscriptionCount = 0;

const trackSubscriptions = <T>() =>
  tap<T>({
    subscribe: () => {
      subscriptionCount++;
      console.log('📈 Subscriptions:', subscriptionCount);
    },
    unsubscribe: () => {
      subscriptionCount--;
      console.log('📉 Subscriptions:', subscriptionCount);
    }
  });

// Usage example
const stream$ = interval(1000).pipe(
  trackSubscriptions()
);

const sub1 = stream$.subscribe();
// Output: 📈 Subscriptions: 1

const sub2 = stream$.subscribe();
// Output: 📈 Subscriptions: 2

setTimeout(() => {
  sub1.unsubscribe();
  // Output: 📉 Subscriptions: 1
}, 3000);

Scenario 5: You don't notice an error

  • Symptom: Error occurs, but is not displayed and is ignored

Without an error handler, the error may be held in a grip and unnoticed.

ts
import { of, throwError } from 'rxjs';
import { mergeMap, catchError } from 'rxjs';

// ❌ Error is suppressed because there is no error handling
of(1, 2, 3)
  .pipe(
    mergeMap(value => {
      if (value === 2) {
        return throwError(() => new Error('Error'));
      }
      return of(value);
    })
  )
  .subscribe(); // No error handler

// ✅ Proper error handling
of(1, 2, 3)
  .pipe(
    mergeMap(value => {
      if (value === 2) {
        return throwError(() => new Error('Error'));
      }
      return of(value);
    }),
    catchError(error => {
      console.error('🔴 Caught error:', error.message);
      return of(-1); // Fallback value
    })
  )
  .subscribe({
    next: value => console.log('Value:', value),
    error: error => console.error('🔴 Error on subscribe:', error)
  });

// Output:
// Value: 1
// 🔴 Caught error: Error
// Value: -1

Configure global error handler

A global handler can be configured to catch all outstanding errors.

ts
import { Observable } from 'rxjs';

// Catch all unhandled errors
const originalCreate = Observable.create;

Observable.create = function(subscribe: any) {
  return originalCreate.call(this, (observer: any) => {
    try {
      return subscribe(observer);
    } catch (error) {
      console.error('🔴 Unhandled error:', error);
      observer.error(error);
    }
  });
};

Scenario 6: I want to track retry attempts

  • Symptom: I'm using the retry operator, but I don't know how many retries I'm getting.

When retrying automatically when an error occurs, tracking how many retries are actually performed would facilitate debugging and logging.

Basic Retry Debugging

Use retryWhen to log the number of retries.

ts
import { throwError, of, timer } from 'rxjs';
import { retryWhen, mergeMap, tap } from 'rxjs';

throwError(() => new Error('Temporary error'))
  .pipe(
    retryWhen((errors) =>
      errors.pipe(
        mergeMap((error, index) => {
          const retryCount = index + 1;
          console.log(`🔄 Retry attempt ${retryCount}`);

          if (retryCount > 2) {
            console.log('❌ Maximum retry count reached');
            throw error;
          }

          return timer(1000);
        })
      )
    )
  )
  .subscribe({
    next: value => console.log('✅ Success:', value),
    error: error => console.log('🔴 Final error:', error.message)
  });

// Output:
// 🔄 Retry attempt 1
// 🔄 Retry attempt 2
// 🔄 Retry attempt 3
// ❌ Maximum retry count reached
// 🔴 Final error: Temporary error

TIP

For more detailed implementation patterns on debugging retries, see the "Debugging Retries" section of retry and catchError.

  • Basic tracking using the tap error callback
  • Detailed logging with retryWhen
  • Exponential backoff and logging
  • RxJS 7.4+ retry configuration object

Summary

Solutions to common debugging scenarios

  • values do not flow → forgot to subscribe, check filtering conditions
  • Value different than expected → beware of operator order, reference sharing
  • Subscription not completed → use take or takeUntil for infinite streams
  • Memory leak → auto unsubscribe with takeUntil pattern
  • Missing errors → implement proper error handling
  • retry tracking → logging with retryWhen or configuration object

Released under the CC-BY-4.0 license.