- Published date
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,awaitcontinuations),queueMicrotasklive 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)
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
.thenstill 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
.thenmicrotask loads the index only after the timer fires.
Passing sequence (flush microtasks first)
Diagram explanation:
- The test renders the component and triggers input, scheduling the debounce timer.
- The test flushes microtasks (
await Promise.resolve()); the mock’s.thennow 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(); // failsFixed 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.
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(), noadvanceTimersByTime). - 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.