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
subscribeand 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.
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.
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.
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.
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 valueScenario 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.
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, 8Cause 2: Unintended changes due to shared references
Because JavaScript objects are passed by reference, it is possible to modify the original object.
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.
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: 3Scenario 3: Subscription not completed (infinite stream)
- Symptom:
completeis not called and the stream is not terminated
You need to explicitly complete it, since interval, fromEvent, etc. keep issuing values indefinitely.
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.
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.
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.
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.
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.
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: -1Configure global error handler
A global handler can be configured to catch all outstanding errors.
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
retryoperator, 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.
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 errorTIP
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
takeortakeUntilfor infinite streams - ✅ Memory leak → auto unsubscribe with
takeUntilpattern - ✅ Missing errors → implement proper error handling
- ✅ retry tracking → logging with
retryWhenor configuration object
Related Pages
- Basic Debugging Strategies - How to use tap operator and developer tools
- Custom Debug Tools - Named streams, debug operators
- Performance Debugging - Subscription monitoring, memory usage checking
- Error Handling - Error handling strategies