FrontendAtlas
Interview Questions
Home>JavaScript interview questions> JavaScript coding challenges >Poll Until (with Timeout + Abort)

Poll Until (with Timeout + Abort)

intermediate
By FrontendAtlas Editorial · Updated Jul 14, 2026
Implement pollUntil(check, { interval, timeout, signal }) so a real deadline, cancellation, or the first truthy check settles the returned Promise exactly once. The deadline must remain active even while an asynchronous check is still pending, and no retry may be scheduled after settlement.

Arguments

  • check: () => T | Promise<T> — A function that returns a value (or Promise). A truthy result ends polling and becomes the resolve value.
  • options: { interval?: number, timeout?: number, signal?: AbortSignal } — Polling configuration. interval is the delay between attempts. timeout is the max time to wait. signal cancels polling.

Returns

Promise<T> — Resolves with the first truthy value returned by check, or rejects on timeout/abort.
Examples
const result = await pollUntil(async () => {
  const status = await fetchStatus();
  return status === 'ready' ? status : false;
}, { interval: 200, timeout: 5000 });

const ac = new AbortController();
const p = pollUntil(() => isReady(), { interval: 100, signal: ac.signal });
ac.abort(); // cancels polling

Solution

Overview

Run the first check immediately, but keep a separate deadline timer that can settle polling even when the check never settles. Route success, check failure, timeout, and abort through one idempotent settlement helper, and guard immediately after every await before scheduling another attempt.

1

Approach: Recursive setTimeout loop

Steps:

1) Normalize options: interval (default 50), timeout (optional), signal (optional).

2) Create separate retry and deadline timer handles plus one settled flag.

3) Route resolve, check rejection, timeout, and abort through one settle() helper that marks settled before cleanup.

4) Start the optional deadline timer independently of check().

5) Run check() immediately; after its await, stop if another terminal path already won.

6) Resolve a truthy value or schedule one retry only while still pending.

7) Clear both timers and remove the abort listener on every terminal path.

A timeout or abort settles the polling Promise but cannot forcibly interrupt synchronous JavaScript or an in-flight operation that does not observe cancellation. Complexity: O(k) time for k attempts, O(1) extra space.

function toAbortError(reason, message = 'Aborted') {
  if (reason && reason.name === 'AbortError') return reason;
  const err = new Error(reason && reason.message ? reason.message : message);
  err.name = 'AbortError';
  return err;
}

export default function pollUntil(check, options = {}) {
  const { interval = 50, timeout, signal } = options;

  return new Promise((resolve, reject) => {
    let retryTimerId;
    let deadlineTimerId;
    let settled = false;

    const cleanup = () => {
      if (retryTimerId !== undefined) clearTimeout(retryTimerId);
      if (deadlineTimerId !== undefined) clearTimeout(deadlineTimerId);
      if (signal) signal.removeEventListener('abort', onAbort);
    };

    const settle = (callback, value) => {
      if (settled) return false;
      settled = true;
      cleanup();
      callback(value);
      return true;
    };

    const onAbort = () => {
      settle(reject, toAbortError(signal && signal.reason));
    };

    if (signal) {
      if (signal.aborted) {
        settle(reject, toAbortError(signal.reason));
        return;
      }
      signal.addEventListener('abort', onAbort, { once: true });
    }

    if (timeout != null) {
      deadlineTimerId = setTimeout(() => {
        settle(reject, new Error('Timeout'));
      }, Math.max(0, timeout));
    }

    const attempt = async () => {
      if (settled) return;

      try {
        const result = await check();
        if (settled) return;
        if (result) {
          settle(resolve, result);
          return;
        }
      } catch (err) {
        if (settled) return;
        settle(reject, err);
        return;
      }

      if (settled) return;
      retryTimerId = setTimeout(attempt, interval);
    };

    attempt();
  });
}
type PollOptions = {
  interval?: number;
  timeout?: number;
  signal?: AbortSignal;
};

function toAbortError(reason: any, message: string = 'Aborted'): Error {
  if (reason && reason.name === 'AbortError') return reason;
  const err = new Error(reason && reason.message ? reason.message : message);
  (err as any).name = 'AbortError';
  return err;
}

export default function pollUntil<T>(
  check: () => T | Promise<T>,
  options: PollOptions = {}
): Promise<T> {
  const { interval = 50, timeout, signal } = options;

  return new Promise<T>((resolve, reject) => {
    let retryTimerId: ReturnType<typeof setTimeout> | undefined;
    let deadlineTimerId: ReturnType<typeof setTimeout> | undefined;
    let settled = false;

    const cleanup = () => {
      if (retryTimerId !== undefined) clearTimeout(retryTimerId);
      if (deadlineTimerId !== undefined) clearTimeout(deadlineTimerId);
      if (signal) signal.removeEventListener('abort', onAbort);
    };

    const settle = (
      callback: (value: any) => void,
      value: any
    ): boolean => {
      if (settled) return false;
      settled = true;
      cleanup();
      callback(value);
      return true;
    };

    const onAbort = () => {
      settle(reject, toAbortError(signal && (signal as any).reason));
    };

    if (signal) {
      if (signal.aborted) {
        settle(reject, toAbortError((signal as any).reason));
        return;
      }
      signal.addEventListener('abort', onAbort, { once: true });
    }

    if (timeout != null) {
      deadlineTimerId = setTimeout(() => {
        settle(reject, new Error('Timeout'));
      }, Math.max(0, timeout));
    }

    const attempt = async (): Promise<void> => {
      if (settled) return;

      try {
        const result = await check();
        if (settled) return;
        if (result) {
          settle(resolve, result);
          return;
        }
      } catch (err) {
        if (settled) return;
        settle(reject, err);
        return;
      }

      if (settled) return;
      retryTimerId = setTimeout(attempt, interval);
    };

    attempt();
  });
}

Notes & Pitfalls

Pitfalls
  • Checking elapsed time only after await check() means a never-settling check can defeat the timeout.
  • Using one timer handle for both the deadline and retry can leave polling running or clear the wrong timer.
  • Forgetting to remove the abort listener causes memory leaks.
  • Treating falsy values as success can end polling too early.
Edge cases
  • timeout = 0 should timeout immediately if the first check is falsy.
  • check throwing or rejecting should reject immediately and stop polling.
  • signal already aborted should reject without running check.
  • Abort during an in-flight check must not allow that attempt to schedule a later retry.
Techniques
  • Recursive setTimeout for precise control.
  • AbortSignal wiring for cancellation.
  • An independent deadline timer plus idempotent settlement.

Resources

  • MDN – AbortController

Similar questions

Fetch JSON with Timeout + AbortintermediateTake Latest (Abort Previous Requests)intermediateSleep / Delay Promiseeasy

Guides

Frontend interview preparation guideGuideFrontend coding interview questions and prep guideBlueprintJavaScript Problems That Actually Show UpBlueprintBuild Great UI in 60 MinutesBlueprint

Preparing for interviews? Use Frontend Coding Challenges first, then move into a concrete Study Plan before targeted Company Prep.

Open frontend interview questionsBrowse JavaScript interview questionsOpen Essential 60Open Machine Coding HubOpen Frontend Coding ChallengesOpen System DesignOpen Interview Prep GuideOpen System Design BlueprintOpen Framework Prep PathsOpen Study PlansOpen JavaScript mastery study planOpen Company PrepOpen JavaScript Framework Prep Guide
↗Incidents hub
← Prev←63 / 88Next →→