Poll Until a Condition Is True
Polling shows up everywhere systems are eventually consistent: waiting for a job status to flip to `done`, for a file to appear, for a deploy to roll out. This snippet walks from a basic fixed-interval poller to one with a deadline, then to exponential backoff with jitter so a thundering herd does not hammer the upstream. Reach for it any time you need to wait for a remote condition without writing the same retry loop again.
185 views
2
async function pollUntil(check, intervalMs = 100) {
while (true) {
const result = await check();
if (result) return result;
await new Promise((r) => setTimeout(r, intervalMs));
}
}
let basicAttempt = 0;
async function basicCheck() {
basicAttempt += 1;
return basicAttempt >= 3 ? `ready after ${basicAttempt}` : null;
}
pollUntil(basicCheck, 5).then((v) => console.log(v));The simplest polling loop runs check() and either returns its truthy result or sleeps for intervalMs and tries again. await keeps the loop linear and exception-safe: an error in check rejects the outer promise instead of silently retrying. Use this for fast, synchronous-feeling waits where the worst case (hung loop) is acceptable. The major footgun is no upper bound: if the condition never becomes true, the loop runs forever, which is why production code uses the timeout variant.
async function pollUntilDeadline(check, { intervalMs = 100, timeoutMs = 5000 } = {}) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await check();
if (result) return result;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`poll timed out after ${timeoutMs}ms`);
}
let deadlineAttempt = 0;
async function deadlineCheck() {
deadlineAttempt += 1;
return deadlineAttempt >= 2 ? 'ok' : null;
}
pollUntilDeadline(deadlineCheck, { intervalMs: 5, timeoutMs: 100 })
.then((v) => console.log(v))
.catch((e) => console.log(e.message));Comparing Date.now() - start against timeoutMs caps the total wait, regardless of how long any single check takes. Throwing an error on timeout lets the caller decide whether to retry, surface the failure to the user, or fall through to a slow path. Keep the timeout proportional to the upstream's worst-case latency: too tight and you alarm on healthy systems; too loose and a real outage takes minutes to surface. Pair this with structured logging that records start, attempt, and the final outcome.
async function pollBackoff(check, { initialMs = 50, maxMs = 1000, timeoutMs = 5000 } = {}) {
const start = Date.now();
let delay = initialMs;
while (Date.now() - start < timeoutMs) {
const result = await check();
if (result) return result;
const jitter = Math.random() * delay;
await new Promise((r) => setTimeout(r, delay + jitter));
delay = Math.min(delay * 2, maxMs);
}
throw new Error(`poll timed out after ${timeoutMs}ms`);
}
let backoffAttempt = 0;
async function backoffCheck() {
backoffAttempt += 1;
return backoffAttempt >= 3 ? 'ready' : null;
}
pollBackoff(backoffCheck, { initialMs: 5, maxMs: 50, timeoutMs: 500 })
.then((v) => console.log(v))
.catch((e) => console.log(e.message));Doubling the delay on every miss spreads load away from the upstream during slow recovery windows, and the random jitter staggers clients that all started polling at the same wall-clock instant (a deploy, a cron tick). Capping delay at maxMs keeps the worst-case wait bounded so a flaky check does not stretch into multi-second pauses. The total wall-clock time becomes a sum of geometric delays, so adjust timeoutMs accordingly. This shape is the default for SDK polling loops (AWS, Kubernetes, GitHub Actions) and is what you should reach for whenever the consumer is shared infrastructure.
