How to Debug Playwright Tests That Fail Only When Microtasks, Timers, and API Mocks Interleave Differently in CI
By David Frei · August 23, 2026
A practical guide to isolating Playwright flakes caused by event loop timing, setTimeout behavior, and mocked API race conditions in CI, with a decision tree and instrumentation tactics.
A test that only fails in CI, and only when promises, timers, and mocked API responses happen to line up differently, is usually not a “random flaky test.” It is a timing bug with a paper trail.
The useful question is not “Why is Playwright unstable?” It is, “Which layer changed the order of work?” In this class of failures, the app, the test harness, and the mock layer can each be correct on their own while still producing the wrong sequence when the event loop gets busy.
If a test outcome depends on whether a microtask runs before a timer callback, the test is describing a race, not a user flow.
This guide focuses on Playwright tests that fail because of microtask timing, setTimeout scheduling, or mocked API race conditions under CI load. The goal is to isolate the source quickly, then make the ordering explicit enough that the test stops depending on scheduler luck.
First, separate the three timing domains
Before debugging, it helps to distinguish the pieces that often get conflated:
- Microtasks are promise callbacks and
queueMicrotask()work. They run after the current JavaScript stack, before the browser returns to the next task. - Timers are callbacks scheduled by
setTimeout()andsetInterval(). They run on later task turns. - Mocked network responses may be immediate, delayed, fulfilled from a route handler, or delayed by your own test code.
The failure usually happens when the app expects one ordering, but the test or mock layer accidentally enforces another. For background on the browser event loop and tasks, MDN’s references on microtasks and setTimeout are the most direct primary references for the concepts themselves.
The decision tree I use
Start with the smallest reproducible failure and ask these questions in order.
1. Does the failure disappear if you remove the mock?
If yes, suspect the mock layer first.
Typical signs:
- The app behaves with the real backend.
- The failure only appears when
route.fulfill()or a request stub is used. - The mocked response returns too early, or later than the UI code expects.
What to check:
- Whether the app reads state from a response and then schedules follow-up work in a promise callback.
- Whether your mock returns data synchronously when the real backend never would.
- Whether the mock omits a header, field, or status transition that triggers a different branch in the app.
2. Does the failure disappear if you remove timers from the app path?
If yes, suspect setTimeout flakiness or timer-based coordination.
Typical signs:
- The app uses timers to “wait for state to settle.”
- The test passes locally but fails under CI load or in headless mode.
- A short timeout masks a genuine race, but a longer timeout only makes the failure slower.
What to check:
- Whether a timer is being used to wait for an async dependency that should be awaited directly.
- Whether the timer exists only to compensate for DOM update timing or animation timing.
- Whether a hidden retry loop depends on wall-clock timing instead of a state predicate.
3. Does the failure disappear if you make the test wait for an explicit app signal?
If yes, the test harness is probably observing the page too early.
Typical signs:
- The app eventually reaches the right state, but the assertion fires first.
- The test uses
waitForTimeout()or an equivalent fixed sleep. - The assertion reads the DOM before the promise chain that updates it has finished.
What to check:
- A missing
awaitin the test code. - An assertion that looks at the UI before the request has completed.
- A selector that matches intermediate DOM, not the final state.
A compact triage table
| Symptom | Likely layer | First probe |
|---|---|---|
| Passes without API mock, fails with mock | Mock layer | Delay or normalize the mock response |
| Passes locally, fails in CI headless | Timing sensitivity in app or test | Add traces, logs, and explicit waits for state |
| Fails only when a timer is involved | App timing or timer-based test logic | Replace fixed delay with awaited condition |
| Fails after a promise resolves, but before UI updates | Microtask ordering | Wait for the UI signal that follows the promise chain |
| Passes when run alone, fails in full suite | Shared state or timing pressure | Inspect isolation, parallelism, and resource contention |
Instrument the ordering before you change the code
Do not start by rewriting the test. Start by making the order visible.
Add trace output around the request and the DOM update
If a request is mocked, log when the route is matched and when it is fulfilled. In the page, log the moment the code starts, the moment a promise resolves, and the moment the UI changes.
A simple pattern in the test can look like this:
import { test, expect } from '@playwright/test';
test('loads account data', async ({ page }) => {
page.on('console', msg => console.log('browser:', msg.text()));
page.on('request', req => console.log('request:', req.method(), req.url()));
page.on('response', res => console.log('response:', res.status(), res.url()));
await page.route('**/api/account', async route => {
console.log('mock: matched /api/account');
await new Promise(r => setTimeout(r, 25));
await route.fulfill({ json: { name: 'Ada' } });
console.log('mock: fulfilled /api/account');
});
await page.goto('https://example.test/account');
await expect(page.getByText('Ada')).toBeVisible();
});
The point is not the delay itself. The point is to prove whether the UI depends on the mock returning before or after another queued task.
Use Playwright trace and network artifacts
Playwright’s tracing and network inspection features are useful when the failure happens too quickly to reason about from logs alone. The trace documentation and network guidance show the supported mechanisms for observing requests, responses, and timing.
What I look for in a trace:
- Did the request ever leave the page?
- Was the mock attached before navigation?
- Did the assertion happen before the response callback completed?
- Did a later render overwrite the earlier state?
Turn race conditions into deliberate delays
A good debugging trick is to make the scheduler more adversarial on purpose.
If the app assumes the mock returns immediately, add a small artificial delay in the mock. If the app assumes the UI updates in the same turn as a promise resolution, insert a microtask boundary or a timer boundary and see what breaks.
await page.route('**/api/search', async route => {
await new Promise(r => setTimeout(r, 50));
await route.fulfill({ json: { results: [] } });
});
If that delay changes the outcome, the test is exposing an ordering dependency that should be made explicit.
What usually needs to change
In the app, remove timing as control flow
If the app uses setTimeout() to wait for state that already has a signal, replace that timer with the real completion event.
Bad pattern:
- Start request.
setTimeout(() => render(), 10).- Hope the promise has resolved.
Better pattern:
awaitthe fetch or async action.- Render after the state is known.
- Let the DOM update become a consequence, not a guess.
This matters because timers are a coarse coordination tool. They do not express readiness. They express hope.
In the test, wait for the result the user can see
Avoid fixed sleeps unless you are debugging. In Playwright, prefer assertions that wait for the visible state you expect, rather than waiting for time to pass.
For example:
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible();
If the app updates after a promise chain, assert on the UI state that chain produces. Do not assert that some intermediate function was called unless you are testing a unit boundary, not browser behavior.
In the mock layer, match the real contract more closely
Mocked API race conditions often come from mocks being “too helpful.” They return instantly, omit headers, or skip the transitional states the app sees in production.
A stronger mock should preserve:
- status codes,
- response shape,
- latency characteristics when relevant,
- and order of dependent calls.
If the app fetches /session before /profile, your mock should not let /profile succeed before /session is available unless that ordering is truly valid.
A practical isolation sequence
When I want to know whether the bug is in the app, harness, or mock, I use this sequence:
- Run the test without the mock against a stable endpoint or a local test server.
- Keep the mock, but add logs for route match, fulfill, and UI update.
- Delay the mock by a small amount to expose assumptions.
- Replace fixed waits with explicit assertions on the visible UI state.
- Remove unrelated parallelism in CI to see whether resource pressure is amplifying the timing gap.
If a one-line delay changes the result, the test was already depending on a hidden schedule.
Headless CI timing issues are not magic
CI changes timing for boring reasons, CPU contention, shared IO, browser startup cost, and different rendering speed in headless mode. That is enough to reorder a promise continuation and a timer callback, especially in a test suite that already depends on tight timing.
That is why a test can be logically correct and still unreliable. “Correct” here means the user path is valid. “Reliable” means the test encodes the actual synchronization points.
For browser automation docs, Playwright’s own guidance on auto-waiting and assertions is worth reading closely. The framework can wait for some conditions, but it cannot infer every app-level readiness signal for you.
When the app is the bug, not the test
If your instrumentation shows that the UI state changes only after a timer, or that a promise resolves before the state it should have produced exists, the app likely has an ordering bug.
Common examples:
- rendering from stale closure state,
- clearing a loading flag too early,
- scheduling a second request before the first state update lands,
- or mixing a promise chain with a timer as if they were equivalent.
In that case, fixing the test alone just hides the defect. The right fix is to make the app’s state transitions deterministic, then simplify the test to observe those transitions.
When the test is the bug
If the app behaves correctly in a browser, but the test fails because it asserts too soon, the fix is usually smaller:
- replace
waitForTimeout()with a state-based assertion, - wait for the network call that precedes the visible update,
- attach the mock before navigation,
- avoid reading a selector that matches both loading and loaded markup,
- and make sure every async call in the test is awaited.
A surprising number of “microtask timing” flakes are just missing await statements or assertions that race the DOM.
A rule of thumb I trust
If you can explain the failure only with “the CI was slower,” you probably do not understand the synchronization point yet. Slow is not the root cause. Slow is what made the race visible.
The fix should change one of these:
- the app’s readiness signal,
- the test’s observation point,
- or the mock’s contract.
If it changes none of those, it is probably a bandage.
FAQ
Why do Playwright tests fail because of microtask timing?
Because the test or app is observing state before the promise chain that creates that state has finished. The browser event loop runs microtasks before the next task, so a tiny ordering difference can expose an unawaited async path.
Is setTimeout always bad in tests?
No. It is useful for debugging, and sometimes for simulating latency. It becomes a problem when it is used as a substitute for a real readiness signal.
How do I know whether the mock is the problem?
Remove the mock, or make it slower and more realistic. If the test outcome changes when the mock timing changes, the mock layer is participating in the race.
What is the safest assertion style in Playwright for these failures?
Assert on the visible state that indicates the user flow is complete, not on an arbitrary sleep or an implementation detail that can happen earlier than the UI update.
Should I use traces for every flaky test?
No. Use traces and request logs when you need to prove ordering. Once you identify the synchronization point, simplify the test and keep the instrumentation only where it still adds value.