Observer tests fail intermittently for a small number of reasons, and all of them come down to the same root cause: the test asserted before the browser or the runtime had finished doing something asynchronous. A slower machine simply widens the window in which that is visible.

Problem / Scenario Context

The pattern is familiar. A lazy-loading test passes a hundred times locally and fails once in every fifteen CI runs, always on the same assertion, always with a message that reads as if the feature is broken:

expect(received).toBe(expected)
Expected: "/hero.jpg"
Received: null

Retrying the job makes it pass, which is the worst possible outcome — it teaches the team that the test is unreliable rather than that the wait is missing, and the flake gets an @flaky tag instead of a fix. Meanwhile the same missing wait is quietly present in production code, where nothing retries.

Mechanics Explanation

Four mechanisms produce nearly all observer flakes.

A missing microtask flush. Observer delivery is asynchronous by contract. A test that triggers an entry and asserts on the next line is racing the callback. Locally the microtask queue drains almost instantly and the assertion usually wins by luck of scheduling; under load it does not.

Real timers in a dwell test. Impression tracking pairs an observer with a timer. If the test uses real timers and a 1000 ms dwell, it passes when the runner is idle and fails when the event loop is busy, because the timer fires late. Fake timers remove the machine from the equation entirely.

requestAnimationFrame in a headless browser with no compositor. Code that coalesces work into an animation frame — the rAF batching pattern — depends on frames being produced. A backgrounded or offscreen headless page may throttle them heavily, so work that "always" runs within 16 ms takes hundreds.

Shared module state across test files. A pool or a static instances array that is not reset lets one file's observer be picked up by the next file's helper. This one is not timing-related at all, but it presents identically: intermittent, order-dependent, unreproducible locally where files often run in a different order.

The Same Race, on Two MachinesTwo timelines of the same test. On a developer laptop the microtask queue drains almost immediately after the trigger, so an assertion made on the next line usually runs after the callback. On a loaded CI runner other work is interleaved, the callback lands much later, and the same assertion runs first and fails. The race is identical in both cases; only the odds change.developer laptop — idletrigger()cbassertion runs after the callback — passesshared CI runner — four jobs on the same boxtrigger()other workassertioncbcallback lands after the assertion — fails

Comparison Table: Symptom to Cause

Symptom Likely cause Fix
Fails ~1 run in 20, same assertion Missing microtask flush await flushObservers() before asserting
Fails only in the full suite Module state leaking between files Reset the instances array and any pool in afterEach
Dwell/impression tests fail under load Real timers Fake timers plus explicit advancement
Passes headed, fails headless Throttled animation frames Await the consequence, not a frame count
Fails on the first run after a cold start Fonts loading late and reflowing await document.fonts.ready before measuring
Fails on one CI machine only Different deviceScaleFactor Pin it in the Playwright project config

Minimal Reproducible Example

TypeScript
// Reproduce the flake deterministically by making the queue busy
it('is racy', async () => {
  render(<LazyImage dataSrc="/hero.jpg" />);
  const img = screen.getByRole('img');
  lastObserver().trigger(img, true);
  for (let i = 0; i < 5; i++) queueMicrotask(() => {});   // simulate a loaded queue
  expect(img.getAttribute('src')).toBe('/hero.jpg');      // now fails every time
});

If a test fails with those five extra microtasks in place, it was always a race — the CI machine was simply providing them for you.

Production-Safe Solution

Flush explicitly, in a helper the whole suite shares.

TypeScript
export const flushObservers = () => new Promise<void>((r) => queueMicrotask(() => r()));
export const flushFrames = (n = 1) =>
  new Promise<void>((r) => {
    let left = n;
    const step = () => (--left <= 0 ? r() : requestAnimationFrame(step));
    requestAnimationFrame(step);
  });

Use fake timers for anything with a dwell.

TypeScript
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it('records an impression after one second in view', async () => {
  render(<Impression id="a" />);
  await flushObservers();
  lastObserver().trigger(screen.getByTestId('a'), true, 0.6);
  await flushObservers();          // callback runs and starts the timer
  vi.advanceTimersByTime(1000);    // ONLY now does the dwell complete
  expect(beacon).toHaveBeenCalledTimes(1);
});

The order is the part people get wrong: advancing the clock before the flush does nothing, because the timer has not been created yet.

Reset shared state centrally.

TypeScript
afterEach(() => {
  MockIntersectionObserver.instances.length = 0;
  disposeAll();                    // the shared observer pool
  document.body.innerHTML = '';
});

Prefer consequence-based waits in browser tests, as described in testing ResizeObserver callbacks with Playwright, and never paper over a race with a longer global timeout.

Observer Flush Before Clock Advance, Never AfterTwo orderings of the same three steps. In the broken ordering the fake clock is advanced before the observer callback has run, so no timer exists yet and the advance does nothing, leaving the assertion to fail. In the correct ordering the microtask flush lets the callback run and create the timer, and only then is the clock advanced, so the dwell completes and the beacon fires.broken ordertrigger()advanceTimersByTime(1000)no timer existed yet — the advance did nothingcorrect ordertrigger()await flushObservers()advanceTimersByTime(1000)beacon firesThe flush is what creates the timer. Advancing a clock past a timer that does not exist is a no-op, not a wait.

Retries Hide the Signal You NeedTwo pipeline configurations over twenty runs. With retries enabled, every run reports green and the underlying race is invisible, so it survives into production where nothing retries. With retries at zero, the same race produces a small number of visible failures, which is the signal that gets it fixed.retries: 220 green runs — and the race ships, where nothing retriesretries: 02 red runs — the signal that gets the missing await added

Verification Steps

  • Prove it is a race, not a bug: insert the five extra microtasks from the reproduction above. A genuine race becomes a hundred-per-cent failure.
  • Run the suite in a random file order (--sequence.shuffle in Vitest, --randomize in Jest) locally. Order-dependent state leaks surface immediately.
  • Run with reduced parallelism on CI once to confirm the failure rate tracks machine load rather than a specific test.
  • Grep for waitForTimeout and bare setTimeout in tests. Each one is a candidate cause.
  • Check the retry count is zero. A suite configured with retries hides exactly the failures this page is about.

Common Mistakes to Avoid

  • Raising the global test timeout. That makes a slow failure into a slower failure; it does not remove the race.
  • Adding await new Promise(r => setTimeout(r, 50)). It works locally for the same reason the bug is invisible locally.
  • Enabling test retries to get the pipeline green. The flake is now permanently invisible, and the underlying missing wait usually exists in the production code path too.
  • Assuming headless equals headed. Frame scheduling genuinely differs; anything that waits on requestAnimationFrame needs a consequence-based wait instead.

FAQ

Why does the same test pass locally a hundred times and fail in CI?

Because the assertion races an asynchronous callback and your laptop happens to win the race. A shared runner interleaves other work between the trigger and the assertion, widening the window until the callback lands second. The test was always wrong; the machine only changed the odds.

Should I just add retries to make the pipeline green?

No. Retries hide the failure without removing it, and the missing wait usually exists in the production code path too — where nothing retries. Fix the wait, then keep retries at zero so a new race is visible the day it is introduced.

Why do animation-frame based tests behave differently headless?

A headless page may have no compositor driving frames at the usual rate, so work coalesced into requestAnimationFrame can be delayed far beyond the sixteen milliseconds it takes when a real display is attached. Wait for the observable consequence instead of counting frames.

How do I test a dwell timer without making the suite slow?

Use fake timers and advance them explicitly. The critical detail is ordering: flush the observer callback first so the timer is actually created, then advance the clock. Advancing before the flush does nothing at all, which produces a failure that looks like a broken feature.


↑ Back to Testing Observers in JSDOM & Real Browsers