In the course of my recent work on an endpoint tasked with fetching data from an external server, a peculiar bug surfaced. This external server occasionally threw errors, making the problem more elusive as it happened statistically, and not on every call. Join me as we explore the nuances of this intermittent challenge in asynchronous programming.


The Premise

We all appreciate the speed that async programming brings to the table. However, as is often the case with powerful features, it comes with its own set of pitfalls. Let’s dissect a seemingly innocuous piece of code:

async function otherAsyncStuff() {
  const response = fetchSomething("/api/endpoint");
  const anotherResponse = fetchSomething("/api/another-endpoint");

  // Some more code
  const diskResult = await fetchSomething("/api/disk");

  const results = await Promise.all([response, anotherResponse]);
  return [results, diskResult];
}

Can you spot a problem here? If you can’t, don’t worry, I couldn’t either. But it turns out that there is a problem here, and it’s a big one.

The Intricacies Unveiled

Let’s zoom out and examine this code in a broader context:

async function fetchSomething(someUrl, error = false) {
  console.log(someUrl);
  if (error) {
    throw new Error(`Something went wrong ${someUrl}`);
  } else {
    // Do work for 2 seconds
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log("Work done", someUrl);
    return "some data";
  }
}

async function otherAsyncStuff() {
  const response = fetchSomething("/api/endpoint");
  const anotherResponse = fetchSomething("/api/another-endpoint", true);

  // Some more code
  const diskResult = await fetchSomething("/api/disk");

  const results = await Promise.all([response, anotherResponse]);
  return [results, diskResult];
}

async function asyncStuff() {
  try {
    const result = await otherAsyncStuff();
    return result;
  } catch (e) {
    console.log("error", e.message);
  }
}

function main() {
  console.log("Started running");
  asyncStuff().then(() => console.log("Finished running"));
  console.log("Doing other stuff");
}

main();

Let’s break down the code step by step, bottom to top:

Now, let’s run this code and see what happens. We get the following output:

Started running
/api/endpoint
/api/another-endpoint
/api/disk
Doing other stuff
/(abbreviated)/code.js:4
    throw new Error(`Something went wrong ${someUrl}`);
          ^

Error: Something went wrong /api/another-endpoint
    at fetchSomething (/(abbreviated)/code.js:4:11)
    at otherAsyncStuff (/(abbreviated)/code.js:15:27)
    at asyncStuff (/(abbreviated)/code.js:26:26)
    at main (/(abbreviated)/code.js:35:3)
    at Object.<anonymous> (/(abbreviated)/code.js:39:1)
    at Module._compile (node:internal/modules/cjs/loader:1376:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1435:10)
    at Module.load (node:internal/modules/cjs/loader:1207:32)
    at Module._load (node:internal/modules/cjs/loader:1023:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12)

Node.js v21.1.0

Wait, what? Why did we get an error? We didn’t even get to the Promise.all part! 🤨


The Culprit: Async and Microtasks

Well, it turns out that the const diskResult = await fetchSomething("/api/disk"); line is the culprit.

Here’s the gist:

“But we have a try/catch block in asyncStuff! Why didn’t it catch the error?”


The Fix: Taming Promises

So, how do we navigate this async maze without a crash? Wrap all calls to fetchSomething in the Promise.all call. That would look something like this:

async function fetchSomething(someUrl, error = false) {
  console.log(someUrl);
  if (error) {
    throw new Error(`Something went wrong ${someUrl}`);
  } else {
    // Do work for 2 seconds
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log("Work done", someUrl);
    return "some data";
  }
}

async function otherAsyncStuff() {
  const response = fetchSomething("/api/endpoint");
  const anotherResponse = fetchSomething("/api/another-endpoint", true);

  // Some more code
  const diskResult = fetchSomething("/api/disk");

  const results = await Promise.all([response, anotherResponse, diskResult]);
  return results;
}

async function asyncStuff() {
  try {
    const result = await otherAsyncStuff();
    return result;
  } catch (e) {
    console.log("error", e.message);
  }
}

function main() {
  console.log("Started running");
  asyncStuff().then(() => console.log("Finished running"));
  console.log("Doing other stuff");
}

main();

Now, all promises resolve in our original scope - the one adorned with the try/catch block. The error is caught, and the program survives unscathed.

To add a pinch of versatility, consider using Promise.allSettled instead of Promise.all to handle all of the promises, even if some promises are rejected.


I hope this journey through the async maze was as enlightening for you as it was for me. Happy coding, and may your promises always resolve in the right context!

Further Reading

UPDATE [28.01.2024]: Thanks to Eytan Schulman for several refinements to the article.