The async/await tradeoffs are the price of a keyword that makes asynchronous JavaScript read like synchronous JavaScript. It looks like a clean win, and mostly it is, but it hides three things: it colors your functions, it inserts scheduling points you cannot see, and it changes how errors travel. A recent design-space paper from Brown University makes the sharpest version of the point: across languages that all spell it async/await, no two agree on the semantics, and similar-looking programs run differently.
That paper (discussed on Hacker News) is worth your time. Here is what the abstraction hides in JS specifically.
Async colors your functions
An async function returns a promise, and to get the value out cleanly you await it, which only works inside another async function. So async climbs the call stack. One async leaf turns its callers async, and theirs.
async function load() {
const res = await fetch('/data');
return res.json();
}
// This caller must also be async, or handle the promise by hand.
async function render() {
const data = await load();
}
You cannot drop load() into an ordinary synchronous function and pretend nothing changed. The two colors do not mix for free. That is why adding one async call to a legacy sync codebase so often turns into a refactor that ripples through a dozen files.
Await hides scheduling you did not ask for
The keyword makes the code look sequential, so it is easy to forget that every await is a yield point. Control goes back to the event loop, and your continuation is queued as a microtask.
async function f() {
console.log(1);
await null; // yields, even though nothing is pending
console.log(3);
}
f();
console.log(2);
// prints 1, 2, 3
await null has nothing to wait for, yet console.log(3) still runs after console.log(2). The await reordered your program. Multiply that across a handful of awaits in a request handler and the execution order stops matching the reading order, which is precisely where timing bugs breed.
Errors travel differently than they look
Sync errors bubble up the stack until something catches them. A rejected promise does not, unless you actually await it or attach a handler.
async function save() {
throw new Error('boom');
}
save(); // no await: rejection floats off as unhandledRejection
await save(); // now it throws where try/catch can see it
Drop the await on the first line and the throw does not fail your function. It becomes an unhandled rejection somewhere else, often logged far from the code that caused it. The keyword that made errors look synchronous is the same keyword whose absence makes them vanish. Every missing await is a silently swallowed failure waiting to happen.
So what do you do with this
Not "stop using async/await". It is still the best tool JS has for this. The point is that the syntax sells you an illusion of straight-line code, and the three tradeoffs, function color, hidden scheduling, and error propagation, are always underneath it.
Lint for floating promises so a missing await is a build error, not a 2am page. Assume every await is a place other code can run, and never assume the value on the other side of one is fresh. Treat "reads like sync" as a convenience, not a promise. The Brown paper's uncomfortable finding is that even the language designers do not agree on what this construct means, so the least you can do is know what your runtime does.
Top comments (0)