vitest-retry-testing-real-vs-fake-timers
Vitest Retry Testing: Real vs Fake Timers
Problem
When testing retry logic with exponential backoff in Vitest, using vi.useFakeTimers() with advanceTimersByTimeAsync()
can cause:
PromiseRejectionHandledWarning: Promise rejection was handled asynchronously- Flaky tests that pass/fail inconsistently
- Tests hanging or never resolving
- Unexpected behavior with promise chains inside retry loops
Context / Trigger Conditions
Use this skill when:
- Testing functions that use
setTimeoutfor retry delays with exponential backoff - Tests involve promise chains (async/await) with timer-based retries
- You're seeing
PromiseRejectionHandledWarningin test output - Tests with
vi.useFakeTimers()fail but the code works correctly in production - Testing patterns like: retry utilities, rate limiters, circuit breakers, polling mechanisms
Common test pattern that fails:
it('should retry with exponential backoff', async () => {
vi.useFakeTimers();
const fn = vi.fn().mockRejectedValue(new Error('Network error'));
const promise = withRetry(fn, { maxRetries: 3, baseDelayMs: 100 });
await vi.advanceTimersByTimeAsync(100); // First retry
await vi.advanceTimersByTimeAsync(200); // Second retry
await vi.advanceTimersByTimeAsync(400); // Third retry
await expect(promise).rejects.toThrow(); // ⚠️ Flaky or warnings
vi.useRealTimers();
});
Root Cause
The complexity arises from the interaction between:
- Microtasks vs Macrotasks: Promises run in the microtask queue, while
setTimeoutruns in the macrotask queue - Event Loop Yielding: When fake timers are active, microtasks (Promise callbacks) don't run until the test function "yields" (e.g.,
await Promise.resolve()) - Timer Advancement Timing: If you advance timers before letting microtasks run, code depending on those microtasks isn't ready yet
- Promise Rejection Handling: Async error handling in retry loops can trigger rejections at unexpected times when time is artificially advanced
As noted in the Vitest Fake Timers documentation, you must use async timer APIs (advanceTimersByTimeAsync) instead of synchronous variants to prevent promise/timer deadlocks. However, even with async APIs, coordinating the exact sequence of timer advancement with promise resolution in retry logic is error-prone.
Solution
Option 1: Use Real Timers with Small Delays (Recommended for Retry Testing)
For retry logic with small delays (10-100ms), use real timers instead of fake timers:
describe('withRetry', () => {
beforeEach(() => {
vi.clearAllMocks();
// ✅ No vi.useFakeTimers() - use real timers
});
it('should retry with exponential backoff', async () => {
const fn = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
const config = {
maxRetries: 3,
baseDelayMs: 10, // Small delays for fast tests
maxDelayMs: 100,
timeoutMs: 5000,
};
await expect(withRetry(fn, config)).rejects.toThrow('Failed to fetch');
expect(fn).toHaveBeenCalledTimes(4); // Initial + 3 retries
});
it('should succeed after transient failures', async () => {
const networkError = new TypeError('Failed to fetch');
const fn = vi
.fn()
.mockRejectedValueOnce(networkError)
.mockRejectedValueOnce(networkError)
.mockResolvedValue('success');
const config = {
maxRetries: 3,
baseDelayMs: 10, // Fast enough for tests
maxDelayMs: 100,
timeoutMs: 5000,
};
const result = await withRetry(fn, config);
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(3);
});
});
Benefits:
- ✅ No promise rejection warnings
- ✅ No flaky tests
- ✅ Simpler test code (no timer advancement)
- ✅ Still fast (10-100ms delays finish quickly)
- ✅ Tests actual timing behavior
Trade-offs:
- Tests run slightly slower (~100ms vs instant with fake timers)
- For this use case, the reliability gain far outweighs the minor speed penalty
Option 2: Fake Timers with Careful Microtask Management (Advanced)
If you must use fake timers (e.g., testing very long delays):
it('should retry with exponential backoff', async () => {
vi.useFakeTimers();
const fn = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
const config = {
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 10000,
timeoutMs: 30000,
};
const promise = withRetry(fn, config);
// ✅ Critical: Let microtasks run before advancing timers
await Promise.resolve();
// Advance timers with careful coordination
await vi.advanceTimersByTimeAsync(1000); // First retry
await Promise.resolve(); // Let promises settle
await vi.advanceTimersByTimeAsync(2000); // Second retry
await Promise.resolve();
await vi.advanceTimersByTimeAsync(4000); // Third retry
await Promise.resolve();
await expect(promise).rejects.toThrow('Failed to fetch');
vi.useRealTimers();
});
When to use this:
- Testing delays longer than 1 second (where real timers would slow tests significantly)
- Testing timeout behavior (need to fast-forward many seconds)
- Complex scenarios where you need precise control over timing
Key principles:
- Always use
advanceTimersByTimeAsync(not synchronous variants) - Add
await Promise.resolve()between timer advancements to flush microtasks - Be aware that test complexity increases significantly
Option 3: Selective Timer Mocking
For tests that need both real network behavior (e.g., MSW) and timer control:
beforeEach(() => {
// Only fake specific timer functions
vi.useFakeTimers({
toFake: ['setTimeout', 'setInterval']
});
});
This is particularly useful when combining fake timers with Mock Service Worker (MSW) as noted in Dheeraj Murali's blog.
Verification
After switching to real timers:
- ✅ No
PromiseRejectionHandledWarningin test output - ✅ Tests pass consistently (not flaky)
- ✅ All retry attempts counted correctly (
expect(fn).toHaveBeenCalledTimes(...)) - ✅ Error classification tests pass (transient vs permanent errors)
Test execution time check:
pnpm test tests/unit/dvsa/retry.test.ts
# Should complete in ~2 seconds with real timers (10-100ms delays)
Example: Complete Migration
Before (fake timers - problematic):
describe('withRetry', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers(); // ❌ Causes issues
});
afterEach(() => {
vi.useRealTimers();
});
it('should succeed after transient failures', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValue('success');
const config = {
maxRetries: 3,
baseDelayMs: 100,
maxDelayMs: 1000,
timeoutMs: 5000,
};
const promise = withRetry(fn, config);
await vi.advanceTimersByTimeAsync(100); // ⚠️ Flaky
const result = await promise;
expect(result).toBe('success');
});
});
After (real timers - reliable):
describe('withRetry', () => {
beforeEach(() => {
vi.clearAllMocks();
// ✅ No fake timers
});
it('should succeed after transient failures', async () => {
const networkError = new TypeError('Failed to fetch');
const fn = vi
.fn()
.mockRejectedValueOnce(networkError)
.mockResolvedValue('success');
const config = {
maxRetries: 3,
baseDelayMs: 10, // ✅ Small delay for fast tests
maxDelayMs: 100,
timeoutMs: 5000,
};
const result = await withRetry(fn, config); // ✅ Just await, no timer advancement
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
});
});
Notes
When Real Timers Are Better
Use real timers for retry testing when:
- Delays are small (< 500ms)
- Test suite completion time isn't critical
- Reliability is more important than speed
- Testing exponential backoff, jitter, or rate limiting
When Fake Timers Are Better
Use fake timers for:
- Very long delays (> 5 seconds)
- Timeout testing (need to fast-forward minutes)
- Complex timing scenarios requiring precise control
- Performance-critical test suites where every millisecond counts
Error Types Matter
Always use realistic error types in tests:
- ✅
new TypeError('Failed to fetch')- Real network error from fetch - ✅
{ errorCode: 'MOTH-RL-02' }- API-specific error objects - ❌
new Error('Network error')- Generic errors may be classified as permanent
Vitest Configuration
In vitest.config.ts, you can set global fake timer defaults:
export default defineConfig({
test: {
fakeTimers: {
toFake: ['setTimeout', 'setInterval', 'Date'],
},
},
});
However, for retry testing, it's often better to not set this globally and opt-in per test.
Alternative: Auto-Advancing Timers
Both Jest and Vitest support auto-advancing timers, which advance time automatically when the event loop would otherwise idle. However, this feature is still experimental and may not work well with complex retry logic.
References
- Vitest: Mocking Timers - Official Vitest documentation on fake timers
- Vitest: Fake Timers Config - Configuration options for fake timers
- Testing Library: Using Fake Timers - Best practices for fake timers with Testing Library
- Handling Time in Tests: Reliable Patterns for Async and Delays - Comprehensive guide on testing time-dependent code
- Dheeraj Murali: Vitest Use Fake Timer and MSW - Compatibility issues between fake timers and MSW
- GitHub Issue: Component test with setTimeout and vitest fake timers not working - Discussion of microtask/macrotask issues
- Steve Kinney: Mocking Timers, Dates, And System Utilities - In-depth course on testing with mocked time