skip to content

A lottery app polls draw status with a recursive setTimeout every second, and every Detox test after launch blocks; why, and what changes fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. setTimeout up to 1.5 s counts as busy
  2. setInterval is ignored
  3. recursion means always one pending
  4. switch to setInterval or longer delay
  5. or stop polling when unneeded

basics

~20 s

Detox treats a pending setTimeout of 1.5 seconds or less as busy and ignores setInterval. A recursive one-second setTimeout always has the next timer queued, so the app never idles. Switch the loop to setInterval, lengthen it, or stop polling when not needed.

solid answer

~50 s

Detox synchronizes with JavaScript timers, but selectively: it waits for `setTimeout` timers of up to **1.5 seconds** and ignores `setInterval`. On Android the check asks React Native's timer manager whether any non-repeating timer shorter than 1500 ms is pending. A polling loop written as `function poll() { fetchStatus().finally(() => setTimeout(poll, 1000)); }` always has the next one-second timeout queued, so the app is never idle and the first action after launch blocks until a timeout; the busy log lists the timer. Fixes, in order of preference: stop polling where it is not needed, for example only on the live-draw screen and only while the app is active; rewrite the loop with `setInterval`, which Detox ignores, as its docs suggest; or use a delay longer than 1.5 seconds. Disabling synchronization for the whole run hides the problem and costs all automatic waiting.

code

tsx · 14 lines
tsx
import { useEffect } from 'react';
import { refreshDrawStatus } from './drawApi';

export function useDrawStatusPolling(active: boolean) {
  useEffect(() => {
    if (!active) return;
    // setInterval is ignored by Detox synchronization; a recursive
    // setTimeout(poll, 1000) would keep the app permanently busy
    const id = setInterval(() => {
      void refreshDrawStatus();
    }, 1000);
    return () => clearInterval(id);
  }, [active]);
}

go deeper

for a junior

Recall that Detox waits for short setTimeout timers and ignores setInterval, so a timer loop can keep an app busy.

for a middle

Explain the 1.5 second rule and why a recursive short setTimeout always leaves a pending timer that blocks synchronization.

for a senior

Diagnose the pending timer from the busy log, fix the app's polling (scope it, use setInterval or longer delays), and explain why URL exclusion alone fails.

for a principal

Set app-wide guidance on background polling that serves both battery life and testability, so e2e synchronization is not fought case by case.

## How Detox treats JavaScript timers **Detox** includes timers in its idle check because a short `setTimeout` is often the next step of a UI change: a debounce, a delayed navigation, a toast hide. But waiting for every timer would make many apps untestable, so the rule is selective: | Timer | Tracked by default? | |---|---| | `setTimeout` with a delay of up to 1.5 seconds | yes, the app is busy until it fires | | `setTimeout` with a longer delay | no | | `setInterval`, any period | no | On Android, Detox's timer resource asks React Native's `JavaTimerManager` whether a timer is pending in a 1500 ms window, and that check skips repeating timers. The Detox docs state the same behaviour for the platform as a whole: `setInterval` is ignored and only `setTimeout` calls of up to 1.5 seconds are waited for. ## Why the polling loop blocks The lottery app checks whether tonight's draw has started: ```tsx useEffect(() => { let timer: ReturnType<typeof setTimeout>; const poll = async () => { await refreshDrawStatus(); timer = setTimeout(poll, 1000); }; poll(); return () => clearTimeout(timer); }, []); ``` Each time `poll` finishes, it schedules the next call one second later. From Detox's point of view: 1. there is always a pending `setTimeout` of 1000 ms, which is under the 1.5 second threshold; 2. when it fires, the fetch is in flight, which is also busy; 3. when the fetch returns, a new 1000 ms timeout is queued. The app is **never** idle, so the first action after launch waits until the test times out. The synchronization debug log, printed after `session.debugSynchronization` (10 seconds by default), shows the pending timer. ## Fixes, from best to worst 1. **Poll only when needed.** Start the loop on the live-draw screen, stop it when the screen loses focus or the app goes to the background. That also saves battery and data for real users. 2. **Use `setInterval`.** Detox's troubleshooting guide recommends exactly this for short polling loops: the same one-second cadence written with `setInterval` is ignored by synchronization. Make sure overlapping requests are handled, since an interval does not wait for the previous fetch. 3. **Lengthen the delay** beyond 1.5 seconds if the product allows it. 4. **Exclude the polling URL** with `device.setURLBlacklist()` or the `detoxURLBlacklistRegex` launch argument; this addresses the network half, but the short `setTimeout` itself still keeps the app busy, so it does not fix this loop on its own. 5. **Disable synchronization** only as a last resort, and only around the steps that need it. ## Why not change the tests instead? - Adding sleeps does nothing: the app still never idles, so the next Detox step still waits. - Disabling synchronization in `beforeAll` for every test turns the whole suite into a timing-based black-box suite. - Raising timeouts only makes the run hang longer before failing. The blockage is Detox pointing at a real property of the app: aggressive polling on React Native's single JavaScript thread. The docs put it bluntly: avoid aggressive polling if possible. ## Related timer cases - A **debounce** of 300 ms on a search field is tracked, which is what you want: Detox waits until the debounced search has run before the next step. - A **toast** hidden by `setTimeout(hide, 2000)` is not held by the timer rule because the delay exceeds 1.5 seconds (its show and hide animations still are), so a synchronized test can usually see it; one hidden after 1000 ms is tracked, and the toast is gone by the time Detox checks. - A **retry loop** with exponential backoff starts under the threshold and grows past it, so early retries block and later ones do not, which can make a failing endpoint look like intermittent slowness. ## Summary Know the rule, `setTimeout` up to 1.5 seconds tracked and `setInterval` ignored, recognise a recursive short `setTimeout` as a permanent busy state, and fix the app's polling rather than the tests.

  • Why does Detox ignore setInterval but track short setTimeout calls?
    A short `setTimeout` is usually the next step of a UI change, such as a debounce or a delayed navigation, so waiting for it keeps tests in sync. A `setInterval` is usually a background heartbeat that never ends; tracking it would make the app permanently busy. The 1.5 second limit similarly skips long delays that are not part of an interaction.
  • Would blacklisting the draw-status URL alone unblock the tests?
    No. The blacklist removes the fetch from network synchronization, but the recursive one-second `setTimeout` is still a pending short timer between fetches, so the app stays busy. The loop itself has to change.

saying these in an interview costs you the question

  • Detox waits for every setInterval tick before each action.
  • Detox ignores all JavaScript timers, so polling cannot block it.
  • Blacklisting the polling URL is enough to unblock the loop.
  • Adding sleep calls lets the polling timer finish first.
  • A setTimeout of any length keeps Detox waiting until it fires.