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 at 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 main function is nothing special. It just calls asyncStuff
and prints some stuff to the console (think of asyncStuff
as being your app main loop).
Then, asyncStuff
awaits on otherAsyncStuff
and catches any errors that might occur.
otherAsyncStuff
is where the trouble happens. It calls fetchSomething
twice (think of fetch
or axios
), once with an error and once without.
Then, it awaits on fetchSomething
with the disk endpoint, and then awaits on Promise.all
with 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 we await
the disk call, JavaScript continues running previous calls to fetchSomething
(as they are promises
, which are microtasks
). The second call to fetchSomething
throws 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?”
Well, when anotherResponse
is executed, it happens in a different context without our try/catch
safety 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