Search code examples
javascriptasynchronousasync-awaitpromise

How to wait until all other parallel promises resolve?


I am currently implementing a client-side token refreshing script for a website, and reached a minor problem when the access token needed to be refreshed.

For some pages, the client fetched multiple (let's say two) documents from the server (which both needed to be fetched with authorised requests, that is with an access token), so when the client access token was expired, this simultaneous fetching invoked two token refresh operations, and the client saved the final access token of the refresh operation that concluded last. This is a waste of resources as the first access token was never used or even saved to the client.

In order to solve this, I made the process that refreshed tokens create a temporary localStorage entry:

async function getAccessToken(auth) {
  // Check if the current token is valid, and return it if so
  ...
  // Case when token needs to be refreshed
  localStorage.setItem("TOKEN_PENDING", "TOKEN_PENDING");
  // Refresh the token
  ...
  const res = await fetch(...);
  ...
  // Clean up after refresh
  localStorage.removeItem("TOKEN_PENDING");
  // Return the new token
  ...
}

Obviously, I now need to check if this entry has been created prior to refreshing the token:

const isTokenPending = () => localStorage.getItem("TOKEN_PENDING") != null;

async function getAccessToken(auth) {
  // Check if the current token is valid, and return it if so
  ...
  // Case when token needs to be refreshed
  localStorage.setItem("TOKEN_PENDING", "TOKEN_PENDING");

  // Check if the token is currently being refreshed in a parallel instance of the function
  if (isTokenPending()) {
    while (isTokenPending()) {
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
    console.info("Token finished refreshing");
    // Recursively call the function, which will now produce the new valid token
    return getAccessToken(auth);
  }

  localStorage.setItem("TOKEN_PENDING", "TOKEN_PENDING");
  // Refresh the token
  ...
  const res = await fetch(...);
  ...
  // Clean up after refresh
  localStorage.removeItem("TOKEN_PENDING");
  // Return the new token
}

The problematic part is when the function detects that the temporary entry is present, and waits until the token is refreshed in some other instance of the function. Currently, I've tried the approach seen in the while loop:

while (isTokenPending()) {
  await new Promise((resolve) => setTimeout(resolve, 0));
}

However, I'm not sure this is a good enough solution since it looks hacky and isn't obvious at first glance what's going on. I used a self-resolving promise which resolves after a timeout of 0 milliseconds, which seems not to do anything. However, the fact that the promise is being awaited allows the program to "yield" the asynchronous processing and simultaneously handle the parallel instance which is refreshing the token, allowing the while loop to finally end.

I also tried removing the hackish 0-millisecond promise, but then the process consumed all processing time and didn't allow the parallel instance to refresh the token. This resulted in the website hanging/freezing.

I expected the two invocations of the function to run in parallel, and for the while loop to wait until the second one completed, which would make the next call to isTokenPending() return false.

Additionally, I found this SO post: How to wait for a JavaScript Promise to resolve before resuming function?

However, the accepted answer states that there is no way to achieve this, and it is dated 9 years ago so if this even applies to my problem I'd like to know if anything has changed since then. Perhaps the use of yield?

So what I'm really asking is how could I allow this part of the code (with the while loop) to wait for allow the asynchronous code to finish completing, and then continue when there are no unresolved promises?

Thanks in advance for your proposed solutions.


Solution

  • If you're only worried about issuing one token request per tab/window, this can be done very easily by storing the Promise that will return with a resolved access token. If you are trying to issue one token request per browser even among multiple tabs/windows, you could use the storage event to identify when some other browsing context (tab or window) makes that change.

    Firstly: the question you linked, How to wait for a JavaScript Promise to resolve before resuming function?, effectively asks if you can pause a function that isn't async. That answer is "no", but that's not what you're trying to do here.

    If all you care about is the current tab, then it becomes very easy: separate the logic that refreshes the access token from the logic that gets it.

    /** Private. Always returns a fresh access token. */
    async function refreshAccessToken(auth) {
      // Debug assert.
      if (isTokenPending()) throw new Error("Called during refresh.");
    
      localStorage.setItem("TOKEN_PENDING", "TOKEN_PENDING");
      const res = await fetch(...);
      localStorage.removeItem("TOKEN_PENDING");
      return res;
    }
    
    /** Private. Stores a promise for the current access token in flight. */
    let accessTokenPromise = null;
    
    /** Always returns a valid access token. Could be async if you'd like. */
    function getAccessToken() {
      // If this is the first call, fetch. If the token is stale, fetch.
      // If the token is pending, do not refresh, the promise covers that case.
      if (!accessTokenPromise || accessTokenHasExpired()) {
        // Do not await this. You want to store the promise.
        accessTokenPromise = refreshAccessToken(getAuth());
      }
      // In an async function, you can optionally await this for better debug messages.
      return accessTokenPromise;
    }
    

    If you want to store this between tabs, then it's almost the same, but instead you'd want to detect the case where you have TOKEN_PENDING set but your browsing context isn't the one that initiated it (presumably by storing a local boolean). In that case, you could either poll through a repeated setTimeout or subscribe through a storage event that detects when the other tab has set the token.

    /** Listens for a token update. Uses manual promises to convert event listener. */
    function listenForTokenUpdate() {
      return new Promise((resolve, reject) => {
        let listener = (e) => {
          if (e.key === "TOKEN") {
            window.removeEventListener(listener);
            resolve(e.newValue);
          }
        };
        window.addEventListener('storage', listener);
      });
    }
    
    /** Always returns a valid access token. Could be async if you'd like. */
    function getAccessToken() {
      if (tokenIsPendingFromAnotherTab()) {
        // Another tab is fetching. Wait for it.
        accessTokenPromise = listenForTokenUpdate();
      } else if (!accessTokenPromise || accessTokenHasExpired()) {
        // We need to fetch the new access token ourselves.
        accessTokenPromise = refreshAccessToken(getAuth());
      }
      // Either the promise is still valid or has been recently updated above.
      return accessTokenPromise;
    }
    

    Note that you may need to adjust the above code depending on how exactly you store your token. Though Javascript is effectively single-threaded within a browsing context, if you are syncing data between multiple browsing contexts via local storage, you might see listenForTokenUpdate catch the TOKEN update before other tab has cleared the TOKEN_PENDING status, meaning that the right call order would have your other tab listen for storage event that would never arrive. Likewise you might need to catch the case where both browsing contexts detect that no other tab is fetching and then start fetching simultaneously, though ultimately the cost of that race condition is pretty low.

    
    tab 1 --> set TOKEN_PENDING --> refreshAccessToken ------------------> clear TOKEN_PENDING 
    
    tab 2 -----------> listenForTokenUpdate() -> resolve -> listen again ---------------- ...>