Published date

Making Vitest fake timers work with debounced SolidJS searches

Making Vitest fake timers work with debounced SolidJS searches

Debugging why advancing Vitest fake timers caused empty search results, and how microtasks vs macrotasks (promise vs setTimeout) ordering matters. Includes fixes, diagrams, and alternative design.

I recently added unit tests for a ContentSearch component (SolidJS) that debounces user input (setTimeout(..., 180)). Tests only passed if I actually waited >180 ms using a real timer:

await new Promise((r) => setTimeout(r, 185));

This obviously slowed down the test suite when repeated many times.

Switching to Vitest fake timers (vi.useFakeTimers ) seemed like the right optimization, but the tests started failing. I got no results where I asserted the expected output. Increasing the fake timer duration did not help either.

I investigated further and it turns out that the issue was about the ordering of microtasks (Promises) vs macrotasks (timers) in the event loop.

Event Loop, Call Stack, Microtasks, Macrotasks

A quick mental model:

  • Event Loop: The mechanism in JavaScript runtimes (browsers, Node.js, Bun, etc.) that coordinates the execution of code, handling of events, and scheduling of asynchronous tasks. All spec-compliant runtimes behave the same way.
  • Call stack: The stack of currently executing functions. In the context of the JS event loop, the call stack always refers to synchronous function execution. The event loop waits for the call stack to be empty before moving on to the next phase.
  • Microtask queue: High priority, runs immediately after the current call stack empties and before the next timer, I/O, or rendering step. Promises (.then, await continuations), queueMicrotask live here.
  • Macrotask (task) queue: Lower priority. Timers (setTimeout, setInterval), DOM events, network callbacks, etc. Each event loop iteration takes one macrotask, then drains all microtasks that appeared while handling it.

So the ordering inside one loop turn:

Synchronous code (call stack)
  ↓
Drain ALL microtasks
  ↓
Run next macrotask (e.g., timer, event)
  ↓
Drain microtasks scheduled during that macrotask
  ↓
Repeat: next macrotask...

When using Vitest’s fake timers to move time forward in tests, microtasks (like Promise callbacks) won’t run until the test function “yields” (e.g. await Promise.resolve()). If I advance timers before letting microtasks run, code that depends on those microtasks (such as data loading) won’t be ready yet, which causeed tests to fail.

Diagram: what went wrong vs fixed

Failing sequence (fake timers advanced too soon)

Timer QueueMicrotask QueueComponentTestTimer QueueMicrotask QueueComponentTestTimer callbackenqueuedrender()onMount() schedules fetch(mock resolved Promise)dispatch input(debounce schedules setTimeout)vi.advanceTimersByTime(ms)(before microtasks flush)run debounced search(index still empty)returns empty resultsfetch.then microtask finally runsindex populated after timer

Diagram explanation:

  • The test renders the component and triggers input, which schedules a debounce timer.
  • The mocked fetch returns an already resolved Promise, but its .then still sits in the microtask queue until the stack clears.
  • The timer is advanced before microtasks flush, so the debounced search runs with an empty index and returns no results.
  • The .then microtask loads the index only after the timer fires.

Passing sequence (flush microtasks first)

Timer QueueMicrotask QueueComponentTestTimer QueueMicrotask QueueComponentTestrender()onMount() schedules fetch(mock resolved Promise)dispatch input(debounce schedules setTimeout)await Promise.resolve()(flush microtasks i.e. fetch.then)fetch.then loads indexvi.advanceTimersByTime(ms)debounced search(index ready)returns populated results

Diagram explanation:

  • The test renders the component and triggers input, scheduling the debounce timer.
  • The test flushes microtasks (await Promise.resolve()); the mock’s .then now populates the index.
  • When the timer is advanced, the debounced search runs with the index ready and returns results.

Example

Failing test pattern

This example shows how the component loads its search index asynchronously (via a Promise) and then uses a debounced search.

onMount(() => {
	fetchSearchIndex().then((data) => {
		// ...populate index...
	});
});

const debouncedSearch = debounce((value) => {
	setResults(runSearch(index(), value));
}, 180);

If the test advances timers before the index is populated, the search will run with no data and fail.

vi.useFakeTimers();
render(() => <ContentSearch />);
fireEvent.input(input, { target: { value: 'First' } });
vi.advanceTimersByTime(185); // runs too early
expect(screen.getByText('First Post')).toBeDefined(); // fails

Fixed test pattern (flush microtasks)

This test pattern ensures the search index is loaded before advancing timers, by explicitly flushing microtasks.

vi.useFakeTimers(); // before each

it('shows search results', async () => {
	render(() => <ContentSearch />);
	const input = screen.getByPlaceholderText('Search...');
	input.focus();
	(input as HTMLInputElement).value = 'First';
	input.dispatchEvent(new Event('input', { bubbles: true }));

	await Promise.resolve(); // single microtask flush so fetchSearchIndex().then loads the index
	vi.advanceTimersByTime(185); // trigger debounce immediately

	expect(screen.getByText('First Post')).toBeDefined();
});

Fetch is mocked

In these tests the network layer is not the source of delay. The test uses:

vi.mock('@/lib/search/fetch-index.client', () => ({
	fetchSearchIndex: vi.fn().mockResolvedValue([
		{ id: 'post-1', title: 'First Post', summary: 'Hello world' },
		{ id: 'post-2', title: 'Second Post', summary: 'Another post' },
	]),
}));

mockResolvedValue creates a Promise that is already resolved, but the .then still run as a microtask (never truly synchronous) and it cannot run until the current synchronous test code finishes. That continuation is placed into the microtask queue. So even with the “instant” mock, I still need to yield for one microtask turn before expecting the index to be populated.

The issue was not about network latency. but it was about advancing timers while still inside the same macrotask where the microtask (index population) had not yet executed.

Why a single await Promise.resolve() works

Promise.resolve() schedules a microtask to resolve after the current synchronous code finishes. Awaiting it yields the test back to the event loop so all pending microtasks (including the mocked fetch’s .then) drain before resuming. That ensures the index is loaded before the debounced search fires.

Note

If more microtasks are scheduled, Vitest includes vi.runAllTimersAsync() to asynchronously runs all scheduled timers (including those with asynchronous callbacks like Promises) until the timer queue is empty.

I could replace the explicit waits with vi.runAllTimersAsync(), but the explicit microtask flush makes the intent clearer (for me).

Solid’s reactivity is synchronous by default (no batching by default like React’s concurrent mode). This makes writing tests easier because state updates run immediately when the code path executes, without needing to wait for a re-render phase.

See: Solid’s official docs about its reactivity system.

Alternative: debounce prop to skip timers in tests

Instead of coordinating fake timers + microtask flushes, make the debounce delay configurable and skip the debounce entirely in tests by passing debounceMs={0}. Rather than calling setTimeout(fn, 0) (which would still enqueue a macrotask) the implementation chooses the synchronous path when the value is 0 or below.

Actual implementation excerpt (simplified for clarity):

export default function ContentSearch({ debounceMs = 180, ...props }: Props) {
	const handleSearchInput = (value: string) => {
		setResults(runSearch(index(), value));
	};

	// If debounceMs <= 0 we bypass timers for immediate execution (useful in tests)
	const debouncedSearch = debounceMs > 0 ? debounce(handleSearchInput, debounceMs) : handleSearchInput;
}

Test pattern (immediate mode):

describe('ContentSearch (debounce disabled)', () => {
	it('immediately shows results', async () => {
		render(() => <ContentSearch debounceMs={0} />);
		// Required: flush once so fetchSearchIndex().then populates the index before the first search
		await Promise.resolve();
		const input = screen.getByPlaceholderText('Search...');
		input.focus();
		(input as HTMLInputElement).value = 'First';
		input.dispatchEvent(new Event('input', { bubbles: true }));
		// No second flush needed: search + DOM update are synchronous
		expect(screen.getByText('First Post')).toBeDefined();
	});
});

In this setup the immediate (non-debounced) path still needs the single pre-search microtask flush (to let the index populate) but does not need an additional flush after typing.

Benchmark (rough, Vitest/jsdom env)

I added a small benchmark test loop (100 iterations each).

Benchmark code
describe.only('Benchmark (rough comparison fake timers vs immediate)', () => {
	// These are not strict performance tests; they just compare relative run time of looped searches.
	const iterations = 100;

	function time(label: string, fn: () => Promise<void>) {
		return (async () => {
			const start = performance.now();
			await fn();
			const end = performance.now();
			console.log(`${label} took ${(end - start).toFixed(2)}ms for ${iterations} iterations`);
		})();
	}

	it('compares debounced (fake timers) vs immediate', async () => {
		// Debounced
		vi.useFakeTimers();
		await time('debounced-180ms-fake-timers', async () => {
			for (let i = 0; i < iterations; i++) {
				render(() => <ContentSearch {...props} />);
				const input = screen.getByPlaceholderText('Search...');
				input.focus();
				(input as HTMLInputElement).value = 'First';
				input.dispatchEvent(new Event('input', { bubbles: true }));

				// Only need the microtask flush because the search is deferred via timer (macrotask)
				await Promise.resolve();

				vi.advanceTimersByTime(185);
				expect(screen.getByText('First Post')).toBeDefined();
				cleanup();
			}
		});

		// Immediate
		vi.useRealTimers();
		await time('immediate-0ms', async () => {
			for (let i = 0; i < iterations; i++) {
				render(() => <ContentSearch {...props} debounceMs={0} />);

				// Search runs synchronously after index is populated in a .then() (microtask)
				await Promise.resolve();

				const input = screen.getByPlaceholderText('Search...');
				input.focus();
				(input as HTMLInputElement).value = 'First';
				input.dispatchEvent(new Event('input', { bubbles: true }));
				expect(screen.getByText('First Post')).toBeDefined();
				cleanup();
			}
		});
	});
});

Approximately speeds-up about 50x for this focused scenario (on my machine). Most of the debounced time is advancing fake timers + repeated setup.

Pros:

  • Simpler test code (no vi.useFakeTimers(), no advanceTimersByTime).
  • Much faster feedback (near-synchronous).
  • Avoids subtle ordering bugs.

Cons / trade-offs:

  • Adds a prop that is only useful for tests (unless I want to use this in prod).
  • If app depends on actual delays (e.g. users waiting for search results), tests using debounceMs=0 will not detect bugs that occur only when requests are genuinely delayed.

I may use both patterns in tests: the immediate mode for component without debounce, and the fake timers mode for components where I want to test the actual debounced behavior.

Recap

  • Promises (microtasks) always run before timers (macrotasks) once the stack clears.
  • Fake timers only fast-forward macrotasks; they do not magically flush pending microtasks still waiting for the stack to unwind (even for already-resolved mocked Promises).
  • In tests, scheduling both a promise continuation and a timer inside one synchronous block requires yielding (e.g. await Promise.resolve()) before advancing timers if the timer callback depends on the promise work.

Closing thoughts

The issue was not Solid-specific. It was the classic event loop ordering: I advanced time before allowing asynchronous initialization to finish.

As I get familiar with the event loop phases, these timing bugs become predictable and (hopefully!) easier to eliminate.