DEV Community

Cover image for Forever Functional: Waiting with Promises
OpenReplay Tech Blog
OpenReplay Tech Blog

Posted on • Edited on • Originally published at blog.openreplay.com

Forever Functional: Waiting with Promises

by author Federico Kereki

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
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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 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);
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

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] */
  }
};
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

(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

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue.
It’s like having your browser’s inspector open while looking over your user’s shoulder.
OpenReplay is the only open-source alternative currently available.

OpenReplay

Happy debugging, for modern frontend teams - Start monitoring your web app 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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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));
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)