Waiting with Promises
Promises are a Functional Programming (FP) concept — though in that area they go by the much-feared name of ”monads”! In this article, we’ll see how applied FP to solve a programming challenge achieving an elegant, short solution, through promises and higher-order functions.
In our specific case, the situation to solve was this: wait until something (external) happened, and efficiently implement this check. The kind of condition we had to look for was if a service was up and running; other possibilities could be if a web worker had finished its work, if a file was available, if enough time had passed, or any combination of similar conditions. (Note that API calls don’t need any special considerations; they are already easily handled with promises.) This kind of check can be solved with reactive programming, using some library like RxJS. But it’s interesting to work things out by ourselves, so in this article we’ll see several solutions for this “waiting problem”, in growing order of completeness, using several FP techniques as mentioned above.
First (bad) solution - just loop
Let’s start by assuming we have a condition()
function to test if your condition has been achieved or not. We need to call this function again and again until it returns true, so the first idea we would try is using a common loop. Note that the function is a common, synchronous one; we’ll consider an async function later.
while (!condition()) {
// nothing
}
This certainly does the job, but it blocks everything else. In the front end, it would totally halt the browser (and possibly even lead to it not responding) and in the back end it would disable Node from doing anything else… a bad solution all around!
Second (better) solution — sleep and loop
Since testing for the condition continuously is out of the question, we could add some delay between tests. Using a promise is a simple way to let your code “sleep” for some period. We can achieve that in the following way.
const timeout = (time) => new Promise((resolve) => setTimeout(() => resolve(true), time));
If you call timeout(1000)
it will return a promise that after 1000 milliseconds (1 second) will resolve to true. So, we can now write code as the following.
// test condition() every second
while (!condition()) { /* [1] */
await timeout(1000); /* [2] */
}
// now condition() is true
This loop tests the condition [1] and if it’s not achieved, it will wait one second before testing again [2]. This does away with the possibility of blocking your browser or disabling Node, so it’s a clear win. But since this coding pattern is something we’ll potentially need again, we can refactor it and even add more features.
Third solution - promises and intervals
Let’s go for a higher-order function solution: we’ll write a function that will produce a promise that we can await. You won’t have to write any loops, because the promise itself will do that. When the promise gets resolved, your logic will go on. Let’s call our function until(...)
because we want to be able to write something like what follows.
// test condition() periodically
await until(condition)
// when the promise is resolved, condition() is true
You must admit that this code reads well — it’s practically “wait until condition”, which is exactly what it does! How can we code this? Instead of having a promise set a timeout to just delay resolving itself, let’s use an interval (meaning, something will get done periodically) to test the condition, and only resolve the promise when the condition is true. By default let’s wait a second between attempts, but we can make this time a parameter for more flexibility.
const until = (fn, time = 1000) =>
new Promise((resolve) => {
const timer = setInterval(() => { /* [1] */
if (fn()) { /* [2] */
clearInterval(timer); /* [3] */
resolve(true); /* [4] */
}
}, time);
});
Our until(...)
function has two parameters: the function to be called to test for the condition, and a delay between successive tests. A timer is set [1] to repeatedly check the function; when that function returns true [2] the interval will be cleared [3] (so no more tests will be performed) and the promise will be resolved [4]. Now waiting for a condition is just a one-liner (as we saw earlier) but there are some details to fix.
Fourth solution - why wait?
We achieved the kind of solution that we wanted, but our implementation has a slight problem: what if the condition was already satisfied? As written, we will wait for an interval until testing: a waste of time. (Our second solution didn’t have this problem; we took a step backward with the newer solution?) We want to test the condition straight away, and only if false do the interval logic. Fortunately, the change is not hard; just check first, and only do the interval loop if the condition wasn’t already true.
const until = (fn, time = 1000) => {
if (fn()) { /* [1] */
return Promise.resolve(true); /* [2] */
} else { /* [3] */
return new Promise((resolve) => {
const timer = setInterval(() => {
if (fn()) {
clearInterval(timer);
resolve(true);
}
}, time);
});
}
};
We just added an initial test [1] and if the condition is already fulfilled, we return a promise resolved to true [2] so there will be no loop. Otherwise [3] we just proceed as in the previous version of until(...)
with the interval, test, etc. This is a better, speedier solution, but there’s still something to be handled… what about errors?
Fifth solution — What about crashes?
The previous solution is good enough… but what happens if the test function crashes? In this case, the promise should be rejected, so the situation can be detected and handled. We’ll have to add some try...catch
blocks to deal with the problem.
const until = (fn, time = 1000) => {
try { /* [1] */
if (fn()) {
return Promise.resolve(true);
} else {
return new Promise((resolve, reject) => {
const timer = setInterval(() => {
try { /* [2] */
if (fn()) {
clearInterval(timer);
resolve(true);
}
} catch (e) { /* [3] */
clearInterval(timer); /* [4] */
reject(e); /* [5] */
}
}, time);
});
}
} catch (e) { /* [6] */
return Promise.reject(e); /* [7] */
}
};
A first try...catch
block [1] is needed for the initial test; if it crashes [6] we’ll return a rejected promise with the error object [7]. We also need a second try...catch
block [2] in the internal loop; on a crash [3] we must clear the interval timer [4] and then reject the promise [5] with the error object.
With this new logic, our test would look like the following code.
// test condition periodically
try {
await until(condition)
// success: the condition was true
} catch (e) {
// failure: the test crashed
}
(Of course, if you are 100% sure that testing the condition can never throw an exception, you need not use try...catch
— but it’s a good practice anyway.)
We’re almost done… but there’s still a hitch!
Open Source Session Replay
OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.
Start enjoying your debugging experience - start using OpenReplay for free.
Sixth (and final) solution — adding a deadline
Our previous solution is almost perfect, but what if the condition is never fulfilled? That’s a problem: your code will be in an infinite loop. We should add a third parameter, with a maximum wait time. If the condition hasn’t become true in that time, let’s assume there was some problem and it won’t ever be true.
The needed changes are short, fortunately: just a matter of keeping time and seeing if we exceeded our maximum wait. We’ll add an extra parameter to define the maximum wait, which by default will be 10 seconds.
const until = (fn, time = 1000, wait = 10000) => {
const startTime = new Date().getTime(); /* [1] */
try {
if (fn()) {
return Promise.resolve(true);
} else {
return new Promise((resolve, reject) => {
const timer = setInterval(() => {
try {
if (fn()) {
clearInterval(timer);
resolve(true);
} else if (new Date().getTime() - startTime > wait) {
clearInterval(timer); /* [2] */
reject(new Error('Max wait reached')); /* [3] */
}
} catch (e) {
clearInterval(timer);
reject(e);
}
}, time);
});
}
} catch (e) {
return Promise.reject(e);
}
};
The needed changes are small: we’ll store the starting time [1] in case we have to set up an interval. In the loop, after testing the condition, if it wasn’t achieved, and enough time has passed we clear the interval [2] and reject the promise [3]. Now we’ve covered all possibilities!
An async version
In the previous code, we worked under the assumption that the condition-testing function was synchronous, but what it if was an async function? We can adapt our code very simply.
const untilAsync = async (fn, time = 1000, wait = 10000) => {
const startTime = new Date().getTime(); /* [1] */
for (;;) { /* [2] */
try {
if (await fn()) { /* [3] */
return true;
}
} catch (e) { /* [4] */
throw e;
}
if (new Date().getTime() - startTime > wait) {
throw new Error('Max wait reached'); /* [5] */
} else { /* [6] */
await new Promise((resolve) => setTimeout(resolve, time));
}
}
};
We’ll store the starting time [1] in case the test takes too long. We’ll have an infinite loop [2] that we’ll exit if the test function resolves to true [3] or if it throws an exception [4]. If the function resolves to anything else but a truthy value and it doesn’t throw an error, we’ll check if enough time has passed; if so, we’ll throw a timeout exception [5]. Otherwise, there’s still time [6] so we’ll wait some time and test again.
The key difference here is that we don’t need to use setInterval(...)
because we’re in an async function, so we can directly await
the needed time. But if you compare both version of our functions, the parallels are clear:
- store the starting time to avoid infinite waits
- if the test is true, succeed
- if the test throws an exception, fail
- if the maximum wait has been reached, fail
- wait some time before testing again
- keep doing this until done, either through an interval or a common loop
Now we have the two versions that we need to check for any condition — you’ll only have to pick which one to use.
Summary
In this article, we’ve seen how to solve a seemingly trivial problem —waiting for something, both synchronically and asynchronically!— in a way that doesn’t cause problems at the browser or the server, by applying FP techniques: higher-order functions and promises. You may even find out that you were already using FP without knowing it!