Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Implementing a Delay Operator with Vitest Fake Timers

Recommended resources

The freely available resources this lesson will direct you to.

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

In our last lesson, we implemented combineLatest, a powerful operator for synchronizing the state of multiple observables. This concluded our initial exploration of combination operators.

Today, we are still in Module 5: Pipeable Operators: Combination & Timing, but we are shifting our focus to the second theme: timing. We will begin implementing operators that manipulate the flow of emissions over time. Our first and most fundamental timing operator is delay.

Our learning goal for this lesson is to use Vitest's fake timers to implement and test a delay operator to shift emissions in time. This will introduce you to a critical testing technique for any asynchronous, time-dependent logic, ensuring our tests are both fast and reliable.

1. The Challenge of Testing Time

Operators like delay rely on functions like setTimeout. If we were to test a delay(5000) operator in a test suite, we would have to wait 5 seconds for the test to complete. This is inefficient and makes for a very slow testing cycle.

The solution is to use fake timers. These tools allow us to mock the browser's or Node.js's time-keeping functions (setTimeout, setInterval, Date). We can then manually control the passage of time in our tests, making a 5-second delay pass in an instant.

Vitest has excellent built-in support for fake timers. Let's review the documentation to see how it works.

Mocking | Guide | Vitest v1.6

The official Vitest documentation explains how to mock timers. This is the foundation for testing our delay operator.

Please read the 'Timers' section. Pay close attention to the roles of vi.useFakeTimers(), vi.runAllTimers(), and vi.advanceTimersByTime(). Notice how the tests can verify the behavior of setTimeout without actually waiting.

As you saw, we can wrap our test logic with vi.useFakeTimers() and vi.useRealTimers() (or vi.restoreAllMocks()) and then use vi.advanceTimersByTime(ms) to simulate the passage of time deterministically.

2. Designing the delay Operator

The delay operator is a pipeable operator. It takes a duration in milliseconds and returns a function that transforms a source observable into a new observable. This new observable will emit all the same notifications (next, error, complete) as the source, but only after the specified time has elapsed.

Here's the implementation strategy:

  1. Return a new Observable: The operator function will return a new Observable.
  2. Subscribe to the source: The producer function of this new observable will subscribe to the source observable.
  3. Schedule notifications:
    • When the source emits a next value, we won't pass it to the observer immediately. Instead, we'll use setTimeout to schedule the observer.next(value) call for a future time.
    • The same logic applies to error and complete notifications. They will also be scheduled with setTimeout.
  4. Manage resources: What happens if a consumer unsubscribes from our delay observable before the timeout has finished? We would have a pending setTimeout that could execute and potentially cause errors or memory leaks. Therefore, our teardown logic must clear all pending timers using clearTimeout. We'll need to keep track of all the timer IDs created by setTimeout.

3. Your Task: Implement and Test delay

Now, let's put this design into practice.

3.1. Implementation

Create a new file at src/operators/delay.ts. This will be a pipeable operator, so its structure will be a function that returns another function.

// src/operators/delay.ts

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

export function delay<T>(duration: number): (source: Observable<T>) => Observable<T> {
  return (source: Observable<T>) =>
    new Observable<T>(observer => {
      // A subscription to manage all timers
      const timerSubscriptions = new Subscription();

      const sourceSub = source.subscribe({
        next: value => {
          const timeoutId = setTimeout(() => {
            observer.next(value);
          }, duration);
          timerSubscriptions.add(() => clearTimeout(timeoutId));
        },
        error: err => {
          const timeoutId = setTimeout(() => {
            observer.error(err);
          }, duration);
          timerSubscriptions.add(() => clearTimeout(timeoutId));
        },
        complete: () => {
          const timeoutId = setTimeout(() => {
            observer.complete();
          }, duration);
          timerSubscriptions.add(() => clearTimeout(timeoutId));
        },
      });

      // When the output observable is unsubscribed,
      // we must unsubscribe from the source AND clear any pending timers.
      return new Subscription(() => {
        sourceSub.unsubscribe();
        timerSubscriptions.unsubscribe();
      });
    });
}

Notice how we use a Subscription instance (timerSubscriptions) not just for observables, but as a generic container for teardown logic. Adding a function like () => clearTimeout(timeoutId) to it ensures that all our timers will be cleared upon unsubscription.

3.2. Testing

Now, create the corresponding test file src/operators/delay.test.ts. This is where you'll apply your knowledge of Vitest's fake timers.

Setup:
Remember to enable and disable fake timers for each test to ensure isolation.

// src/operators/delay.test.ts
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { of } from '../operators/of'; // Assuming you have this
import { Observable } from '../observable';
import { delay } from './delay';

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

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

  // ... your test cases go here
});

Test Cases to Implement:

  1. Delay next emissions:

    • Create an observable of(1, 2).
    • Pipe it through delay(500).
    • Subscribe with a mock observer: const observer = { next: vi.fn() };.
    • Assert observer.next has not been called yet.
    • Advance time by 499ms: vi.advanceTimersByTime(499);. Assert it's still not called.
    • Advance time by 1ms: vi.advanceTimersByTime(1);. Assert observer.next has been called with 1 and then with 2.
  2. Delay the complete notification:

    • Use of(1) piped through delay(200).
    • Subscribe with a mock observer that has next and complete spies.
    • Advance time by 200ms.
    • Assert that next(1) was called, and then complete() was called.
  3. Delay the error notification:

    • Create an observable that errors immediately: const source$ = new Observable(obs => obs.error(new Error('fail')));.
    • Pipe it through delay(300).
    • Subscribe with a mock observer that has an error spy.
    • Advance time by 300ms.
    • Assert that error() was called with the correct error object.
  4. Unsubscription should prevent emissions:

    • Use of(1) piped through delay(1000).
    • Subscribe with a mock next spy.
    • Immediately call subscription.unsubscribe().
    • Run all pending timers to completion: vi.runAllTimers();.
    • Assert that the next spy was never called. This proves your teardown logic is working correctly.

4. Broader Context: Marble Testing in RxJS

While Vitest's imperative advanceTimersByTime is powerful, the world of reactive testing has another major paradigm: declarative testing with marble diagrams. This is the standard approach in the RxJS library.

Instead of writing vi.advanceTimersByTime(500), you write a string like "----a--b---|", where each character represents a unit of virtual time and an event. It's a "domain-specific language" (DSL) for describing timed events.

You don't need to implement this now (we will cover it in Module 8), but it's valuable to see how the same delay operator would be tested in RxJS.

RxJS Unit Testing

This article from the Scott Logic blog provides a fantastic introduction to testing RxJS with its built-in TestScheduler and marble diagrams.

Please read the section 'The Simple Case: Testing the delay operator' and the explanation of the marble syntax that follows. Compare the cold("1-2-3|") and toBe(" 300ms 1-2-(3|)") syntax with how you are using Vitest's timers. This will give you a glimpse into a more advanced testing technique we'll build later.

Seeing this alternative approach highlights a key design choice in testing frameworks: imperative control (Vitest) versus declarative scheduling (RxJS TestScheduler). Both achieve the same goal of deterministic, fast, asynchronous testing.

Conclusion

Great job! You have successfully implemented and tested your first timing operator. This is a significant step, as manipulating time is a cornerstone of reactive programming, enabling features like debouncing, throttling, and timeouts.

Key Takeaways:

  • The delay operator uses setTimeout to shift all observable notifications (next, error, complete) forward in time.
  • Crucially, it must clean up any pending timers on unsubscription to prevent memory leaks and side effects.
  • Vitest's fake timers (vi.useFakeTimers, vi.advanceTimersByTime) are essential for writing fast, deterministic, and reliable tests for time-dependent code.
  • Testing for unsubscription behavior is just as important as testing for the primary functionality.

In our next lesson, we will build directly on this foundation. We'll implement debounceTime, an operator that discards emissions that are followed too quickly by another, which is extremely useful for handling user input like search-as-you-type fields. You'll find that your new skills with fake timers will be indispensable.

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

Sign up