Menu

Diagnosing a common source of race conditions in JS

My thoughts on why expression statements can be a source of bugs—especially in async code—and a proposal for reviving JavaScript's nearly unused void operator.


What’s an expression statement?

1 + 3 is an expression. It’s like writing 4 in JS except you make the computer figure out the answer for you. console.log(1 + 3); is a statement. Arguments to functions are always expressions. And console.log(x); is a statement… because it has a semicolon at the end?

Let’s consider that a working definition rather than a technical one. But in JS you can also write 1 + 3; as a statement. You can’t write console.log(1 + 3;);. Even though assignment is usually done in a statement, like x = 4;, the syntax x = 4 is actually an expression. You can write console.log(x = 4); which will assign x to 4, evaluate to 4, and then console.log that.

Expression statements exist for so we can use expressions that have side effects.

Async functions make things even harder

JS already has confusing things like array.sort(), which both modifies the array (a side effect) and returns a value. With async functions not only are the side effects hard to discover, but the completion of the function is easy to ignore as well. If you try to use the return value of a function, but it’s actually a Promise, this usually isn’t too bad to figure out. But if you’re “throwing away” the result via an expression statement, it can be really hard to track down the stray promise.

someAsyncFunction();
await anotherAsyncFunction();

Imagine if these functions had less obvious names:

load();
await update();

And someone went through and refactored load to be aysnc when it wasn’t before. Now your code has a race condition, where the order of side effects can differ based on how long the different code sections take.

What if it was an error to ignore function return values?

If there was a way to make JS complain when we don’t use the return values from functions, we could use the existing void operator to explicitly discard return values we don’t care about:

const myArray = [3, 2, 1];

// LINTER ERROR: return value not used or ignored
myArray.sort();

// OK: return value explicitly discarded
void myArray.sort();

The no-floating-promises rule from typescript-eslint actually uses this exact approach! This sort of behavior would be hard to add to JS without engine level changes, but a linter plugin for TypeScript can somewhat reliably catch issues like this. Because void doesn’t change behavior in statement context, it’s safe to add to your existing code without changing runtime behavior.


Thanks for reading

Let's talk about this post. I love making friends and chatting.

Subscribe to my RSS feed to stay up to date. What is RSS?