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:
- The 
mainfunction is nothing special. It just callsasyncStuffand prints some stuff to the console (think ofasyncStuffas being your app’s main loop). - Then, 
asyncStuffawaits onotherAsyncStuffand catches any errors that might occur. otherAsyncStuffis where the trouble happens. It callsfetchSomethingtwice (think offetchoraxios), once with an error and once without.- Then, it awaits on 
fetchSomethingwith the disk endpoint, and then awaits onPromise.allwith the two previous calls. - Finally, it returns the results.
 
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:
- When we 
awaitthe disk call, JavaScript continues running previous calls tofetchSomething(as they arepromises, which aremicrotasks). - The second call to 
fetchSomethingthrows an error, and since we didn’t catch it until the next tick of the event loop, the program crashes. 
“But we have a try/catch block in asyncStuff! Why didn’t it catch the error?”
- When 
anotherResponseis executed, it happens in a different context without ourtry/catchsafety net. The error is thrown, and the program crashes. 
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.
Comments