Create your own
Lesson illustration

Implementing and Testing `debounceTime` with Vitest Fake Timers

Hello! Welcome back to our course on building a modern JavaScript framework.

In the last lesson, we successfully implemented the delay operator and got our first taste of testing time-based logic using Vitest's fake timers. This was a crucial skill that we're going to build on immediately.

Today, we continue in Module 5: Pipeable Operators: Combination & Timing by tackling another essential timing operator: debounceTime. This operator is a classic in reactive programming, famous for its utility in handling user interface events.

Our learning goal is to use Vitest's fake timers to implement and test a debounceTime operator to control emission rates. You'll see how the concepts from the delay lesson—managing setTimeout and testing with fake timers—are applied to a more complex and powerful operator.

1. What is Debouncing?

Before we write any code, let's build a solid mental model of what "debouncing" is and why it's so useful. Imagine a user typing into a search box that fetches suggestions from a server. If you send an API request on every single keystroke, you'll overwhelm your server and waste the user's bandwidth.

Debouncing solves this. It's a rate-limiting technique that ensures a function is not called until a certain amount of time has passed without it being called again. In the search box example, we would wait until the user has paused typing for, say, 300ms, and only then send the API request with their complete query.

To see this in action, let's watch a couple of short video clips.

Learn Debounce And Throttle In 16 Minutes

First, this video from Web Dev Simplified provides an excellent, framework-agnostic explanation of the core concept of debouncing. It clearly demonstrates the problem and the solution.

Please watch from the beginning until 03:14. Focus on the distinction between the default behavior (firing on every event) and the debounced behavior (waiting for a pause).

Now, let's see how this concept translates directly to the world of observables with the debounceTime operator.

debounceTime & distinctUntilChanged | RxJS TUTORIAL

This clip from Academind demonstrates the debounceTime operator in RxJS, which is functionally identical to what we are building. This will help you visualize how it works on a stream of values.

Watch from 00:57 to 03:23. Pay attention to how intermediate values are dropped and only the latest value is emitted after the specified period of inactivity.

2. Designing the debounceTime Operator

Based on the videos, we can establish the core logic for our debounceTime operator:

  1. On a next emission from the source:

    • It receives a value.
    • It cancels any previously scheduled emission.
    • It schedules a new emission of the latest value to happen after duration milliseconds.
  2. On an error from the source:

    • It should cancel any pending emission and immediately propagate the error to the observer. Errors should not be delayed.
  3. On complete from the source:

    • If a value is pending (i.e., a timeout is running), that value should be emitted immediately, and then the stream should complete. This is often called "flushing" the last value.
    • If no value is pending, the stream simply completes.
  4. On unsubscribe:

    • Any pending timeout must be cleared to prevent memory leaks and unwanted side effects.
    • The subscription to the source observable must be terminated.

This logic is more complex than our delay operator because we need to manage state: the ID of the pending timeout and the last value received.

3. Your Task: Implement and Test debounceTime

Now it's time to translate this design into code.

3.1. Implementation

Create a new file at src/operators/debounceTime.ts. The structure will be a pipeable operator, just like delay.

Here is the implementation. I've included comments to walk you through the logic, which closely follows the design we just discussed.

// src/operators/debounceTime.ts

import { Observable } from '../observable';

export function debounceTime<T>(duration: number): (source: Observable<T>) => Observable<T> {
  return (source: Observable<T>) =>
    new Observable<T>(observer => {
      let timeoutId: ReturnType<typeof setTimeout> | null = null;
      let lastValue: T;
      let hasValue = false;

      // Helper to emit the last value
      const emit = () => {
        if (hasValue) {
          observer.next(lastValue);
          hasValue = false; // Reset after emitting
        }
      };

      const sourceSub = source.subscribe({
        next: value => {
          // Store the latest value and clear any pending timeout
          lastValue = value;
          hasValue = true;
          if (timeoutId) {
            clearTimeout(timeoutId);
          }
          // Schedule the emission of the latest value
          timeoutId = setTimeout(emit, duration);
        },
        error: err => {
          // On error, cancel any pending emission and propagate immediately
          if (timeoutId) {
            clearTimeout(timeoutId);
          }
          observer.error(err);
        },
        complete: () => {
          // When the source completes, flush any pending value immediately, then complete.
          if (timeoutId) {
            clearTimeout(timeoutId); // Cancel the scheduled emission
            emit(); // and emit now.
          }
          observer.complete();
        },
      });

      // Teardown logic: clear timers and unsubscribe from the source
      return () => {
        if (timeoutId) {
          clearTimeout(timeoutId);
        }
        sourceSub.unsubscribe();
      };
    });
}

3.2. Testing

With the implementation in place, let's write tests to verify its behavior. Create a new test file at src/operators/debounceTime.test.ts.

As you learned in the previous lesson, we must use fake timers to test this operator effectively. Remember to set them up before each test and tear them down after.

// src/operators/debounceTime.test.ts
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { Observable } from '../observable';
import { debounceTime } from './debounceTime';

describe('debounceTime', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  // Your test cases will go here
});

Now, implement the following test cases inside the describe block. These will cover all the critical behaviors of our operator.

  1. Test 1: Should emit only the last value after a pause.

    • Create a test observable that emits values rapidly.
    • Pipe it through debounceTime(200).
    • Use vi.advanceTimersByTime() to simulate time passing.
    • Assert that only the last value is emitted after the debounce duration has passed.
    it('should emit only the last value after a pause', () => {
      const observer = { next: vi.fn() };
      const source$ = new Observable<number>(obs => {
        obs.next(1); // t=0
        setTimeout(() => obs.next(2), 100); // t=100
        setTimeout(() => obs.next(3), 150); // t=150
      });
    
      source$.pipe(debounceTime(200)).subscribe(observer);
    
      // At t=150, value 3 arrives, timer is set for t=350.
      vi.advanceTimersByTime(350); // Move time to t=350
    
      expect(observer.next).toHaveBeenCalledTimes(1);
      expect(observer.next).toHaveBeenCalledWith(3);
    });
    
  2. Test 2: Should not emit if unsubscribed before the time is up.

    • Create an observable that emits one value.
    • Pipe it through debounceTime(500).
    • Subscribe, but then immediately call unsubscribe().
    • Use vi.runAllTimers() to fast-forward past any scheduled events.
    • Assert that the observer's next method was never called.
    it('should not emit if unsubscribed', () => {
      const observer = { next: vi.fn() };
      const source$ = new Observable<number>(obs => obs.next(1));
    
      const subscription = source$.pipe(debounceTime(500)).subscribe(observer);
      
      subscription.unsubscribe();
      vi.runAllTimers();
    
      expect(observer.next).not.toHaveBeenCalled();
    });
    
  3. Test 3: Should propagate errors immediately.

    • Create an observable that emits a value and then immediately errors.
    • Pipe it through debounceTime(1000).
    • Assert that the observer's error method is called right away, without waiting for the debounce timer.
    it('should propagate errors immediately', () => {
      const error = new Error('fail');
      const observer = { next: vi.fn(), error: vi.fn() };
      const source$ = new Observable<number>(obs => {
        obs.next(1);
        obs.error(error);
      });
    
      source$.pipe(debounceTime(1000)).subscribe(observer);
    
      expect(observer.error).toHaveBeenCalledWith(error);
      expect(observer.next).not.toHaveBeenCalled();
    });
    
  4. Test 4: Should flush the last value on completion.

    • Create an observable that emits a value, then completes before the debounce time has passed.
    • Pipe it through debounceTime(500).
    • Assert that the last value is emitted immediately upon completion.
    it('should flush the last value on completion', () => {
      const observer = { next: vi.fn(), complete: vi.fn() };
      const source$ = new Observable<number>(obs => {
        obs.next(1);
        setTimeout(() => obs.complete(), 100);
      });
    
      source$.pipe(debounceTime(500)).subscribe(observer);
    
      vi.advanceTimersByTime(100); // Let the source complete
    
      expect(observer.next).toHaveBeenCalledWith(1);
      expect(observer.next).toHaveBeenCalledTimes(1);
      expect(observer.complete).toHaveBeenCalled();
    });
    

Conclusion

Excellent work! You've now implemented debounceTime, one of the most practical operators in reactive programming. You've reinforced your skills with Vitest's fake timers and handled more complex state management within an operator.

Key Takeaways:

  • debounceTime is a rate-limiting operator that discards emitted values, only passing the most recent one after a specified period of inactivity.
  • Its implementation relies on setTimeout to schedule an emission and clearTimeout to cancel it when a new value arrives.
  • Properly handling error (immediate propagation) and complete (flushing the last value) is crucial for predictable behavior.
  • Testing debounceTime is impossible without fake timers, which allow us to control the passage of time deterministically.

In our next lesson, we'll explore a related but distinct operator: throttleTime. While debouncing waits for a pause, throttling guarantees emissions at a regular interval. Understanding the difference between them is key to mastering rate-limiting in reactive streams.

Can't find a good explanation? Sign up and we'll make it for you

Sign up