Write a helper that polls a condition until it is true or a timeout expires, then tell me what yours does when the condition throws.
A correct wait helper tracks a wall-clock deadline rather than an attempt count, treats an exception from the condition as a not-yet rather than a failure, returns the condition's value, and reports the last observed value or error when it gives up.
What the interviewer is scoring
- Does the candidate bound the wait by a deadline rather than by a fixed number of attempts
- Whether an exception from the condition is handled differently from the condition returning false
- That the last observed value or error survives into the timeout message
- Whether the helper returns the condition's result instead of a bare boolean
- Does the candidate check the condition once before sleeping rather than after
Answer
Why this is asked in an SDET round
It is small enough to finish, and almost every candidate writes something that works on the happy path while getting two or three of the details wrong. Those details are exactly the ones that turn a test suite into a source of undiagnosable intermittent failures, so the exercise reads directly onto the job. If you have written test infrastructure, you have written this function; if you have debugged a flaky suite, you have been failed by somebody else's version of it.
A version that holds up
async function waitFor<T>(
condition: () => T | Promise<T>,
{ timeoutMs = 5000, intervalMs = 100, description = "condition" } = {}
): Promise<T> {
const deadline = performance.now() + timeoutMs;
let lastValue: unknown;
let lastError: unknown;
for (;;) {
try {
const value = await condition();
if (value) return value; // truthy result is the answer, and we hand it back
lastValue = value;
lastError = undefined;
} catch (err) {
lastError = err; // a throw means not-yet, not failure
}
// Checked before sleeping, so an already-true condition costs no delay,
// and the budget is real elapsed time rather than attempts times interval.
if (performance.now() >= deadline) {
throw new Error(
`Timed out after ${timeoutMs}ms waiting for ${description}; ` +
(lastError ? `last error: ${lastError}` : `last value: ${JSON.stringify(lastValue)}`)
);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
The decisions worth defending
A deadline, not an attempt count. The common implementation loops a fixed number of times with a sleep between, which means the real timeout is the attempt count times the interval plus however long the condition itself takes. If the condition is an HTTP call that takes two seconds under load, a helper written as fifty attempts at 100ms becomes a 105-second wait, and in a suite where that helper is used forty times the run duration is now a function of how slow the system under test is. Computing a deadline once and comparing against it makes the bound the thing you asked for. Reading the clock through performance.now() rather than Date.now() is a small correctness point in the same direction: Date.now() can move if the system clock is adjusted, and performance.now() is monotonic.
A throw is not a failure. This is the detail that separates a helper people trust from one they work around. While you are waiting for an element to appear, getText throws because there is no element. While you are waiting for a record to be created, the lookup throws a 404. Those exceptions are the normal state of a condition that has not become true yet, so the helper must swallow them and keep polling, and a helper that propagates them immediately is useless for exactly the cases it exists to handle. The compromise is to swallow them and keep the last one, so the information is not lost.
Report what you saw. A wait that fails with "timed out after 5000ms" and nothing else is the single most common undiagnosable failure in an automated suite, because the two interesting cases produce the same message. "Waited for the order to reach CONFIRMED, last value PENDING" is a slow or stuck workflow. "Waited for the order to reach CONFIRMED, last error: 404" means you are looking at a record that does not exist, which is a different bug entirely. Keeping lastValue and lastError and putting one of them in the message costs three lines and saves hours across a year.
Check first, sleep second. A loop that sleeps at the top pays the interval even when the condition is already satisfied. Across a large suite that is real time for nothing, and it also makes the fast path indistinguishable from the slow one when you are measuring.
Return the value. A helper returning void or boolean forces the caller to fetch the thing again after the wait, which reintroduces the race the wait was meant to remove: the condition was true when polled and the second fetch happens a moment later. Returning T means the caller uses the value that satisfied the condition. The trade-off in the version above is that it treats falsy results as not-yet, so it cannot wait for a legitimately falsy value such as zero; if you need that, take a separate predicate rather than overloading truthiness, and say so when you write it.
Things worth raising even if you do not implement them
Mention what you have left out, because in a coding round the commentary is graded as heavily as the code. Backoff: a fixed interval is right for a short wait and wasteful for a long one against an expensive condition, where growing the interval up to a cap reduces load without much cost in latency. Cancellation: with no way to abort, shutting down a suite means waiting out every outstanding timeout, and threading an abort signal through solves it. Jitter, if many workers poll the same resource on the same cadence.
And name the boundary of the technique. Polling is the right tool when you cannot be notified, which in test code is most of the time. When a real event is available — an awaited promise, a callback, a webhook, a queue consumer — waiting on it is both faster and more accurate, and reaching for a poll instead is how a suite ends up with sleeps disguised as waits.
Likely follow-ups
- How would you make this cancellable so shutting a suite down does not wait out every remaining timeout?
- The condition costs a database query each time. What changes about the polling strategy?
- How would you test this helper without making the test take thirty seconds?
- Where is polling the wrong tool entirely, and what replaces it?
Related questions
- A promise rejects and nothing is awaiting it. What does Node do?hardAlso on async4 min
- You kick off background work from a controller action and it throws once the response has gone out. What is happening?mediumAlso on async5 min
- You await Task.WhenAll over three calls and two of them fail. What does your catch block see?hardAlso on async4 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumAlso on async4 min
- Given an access log, write a utility that reports request count, error rate and p95 latency per endpoint.mediumAlso on sdet5 min
- A client hangs up halfway through a request. How do you structure a Go HTTP service so it stops doing the work?mediumAlso on timeouts7 min
- A dependency that normally answers in 80ms starts taking eight seconds. What in your service reacts, and in what order?hardAlso on timeouts6 min
- You need a second system reading the same PLC data that SCADA already polls. What could that do to the line?hardAlso on polling6 min