Javascript Promises and Async/Await

Intro

I recently came to Javascript from too many years in a C/C++/Python world. Javascript is very familiar in many ways, and different in quite a few as well (see: all the ways to import/export in Javascript — not that Python’s module/import system is so great either.) As a systems-level programmer, I like to know how things work all the way down to the metal. It’s satisfying, but it also really helps with debugging.

One thing I didn’t really understand at a deep level is how Promises and async/await work in Javascript. The examples I read all had to do with waiting for some kind of I/O or a timeout. I wondered “what’s the magic that makes the I/O or timeout wake the main thread back up?” I didn’t know whether that was part of the architecture or just a common-enough use case that nobody bothered talking about other event types.

It turns out there’s no magic, but there’s significant code in the Javascript runtime to make all this work. So now that I figured out how all this stuff works, here’s a working example that will hopefully illuminate some dark corners for those of you, like me, who like to know how things really work rather than just “write it like this and it’ll be fine.”

Event loops and threads and async, oh my!

A few things to know before we start into the example:

Javascript is single-threaded. Promises and async/await are not a replacement for multithreading; you still are only running on one core.

There’s an event loop always running in Javascript. In the browser, it’s run by the browser itself to process user input and network events. In node.js, it’s what runs the functions you specify, and when there’s nothing left to do, it exits. (In recent Pythons there are various event-loop implementations, but they’re layered on — and in C++ it’s a free-for-all as usual.)

Async functions start running immediately when you call them, but when they get to an await on a Promise, that creates a closure of the current stack state, with all local variables and the whole execution context, and that promise + closure gets put on a list of pending functions (more detail below). The event loop runs any “resolved” Promises whenever it gets control back from user code. In this way, it’s like a python generator calling yield. Our example is of a promise that can get woken up from anywhere by calling a function. Here it is. You’ll want to check it out in the JSFiddle.

Our example is of a promise that can get woken up from anywhere by calling a function. Here it is:

You should be able to see the source and its output in that window.

Analysis

So what’s going on? One step at a time:

The functions wakeable and handle_event get defined

we call handle_event(), which starts to run.

At the line await wakeable(), Javascript first calls wakeable() (which will return a Promise), and then passes that Promise to await. At that point that Promise, and the current execution context, gets pushed onto a queue for the event loop to check later. But first, how does the Promise get created in wakeable()?

The Promise constructor takes one arg, a function which itself takes two args (resolve and reject). The promise constructor calls (right now, synchronously) that anonymous function, passing it its own internally-created resolve and reject methods as args. (When we finally get around to calling that resolve, it’ll mark the Promise as resolved.) In our case, the function creates another anonymous function which calls the original resolve (which, remember, was passed into us — it’s internal to Promise), and assigns that function to the global var wakeup. So later when we call wakeup it’ll call the Promise’s resolve method. Whew! Got all that? (It would be harder to make these non-anonymous functions, because they need to be closures to get the original resolve.)

OK, back to top-level. After the call to handle_event returns (it’s async, and the continuation has been put on the queue, but in the main thread, it returns normally), the next thing is we call wakeup().

wakeup is now a function — we created it in step 4. It just calls the Promise’s resolve method. All that actually does is set a flag on the Promise (which is saved on an internal queue in the JS runtime), saying it’s now ready, and it’s resolved successfully. It also saves any value we pass into resolve as the return value you get from awaiting the Promise, or in a .then call. (You can reject as well.)

Next, we set a timeout that will call wakeup again after a while. (I’m not going to cover timeouts here; basically the go on a similar execution queue in the runtime.)

Now we’ve reached the end of the source file. Time to exit, right? Not so fast. There’s still a pending Promise on the queue, so the runtime sees if it’s ready.

It is ready, so the Promise runtime then calls all its .then functions and await continuations — in our case just completing the first iteration of the while loop in handle_event, which loops back around and stops on the next await, creating a new continuation and returning to the runtime’s event loop.

At this point the only thing left on the execution queue is that timeout. The JS runtime waits out the clock, and then calls wakeup again.

As before, wakeup resolves its Promise, which just sets a flag and returns. Now the runtime gets control again, sees that it has a resolved Promise, so calls its continuations, which takes us around the while loop one more time, and back to its await.

At this point there’s nothing left to do; there aren’t any Promises or timeouts or anything. If you run this code in node.js or jsfiddle, it’ll exit. In the browser, the page will just sit there waiting for user input. And that’s all, folks!

More about async and await

All async functions always return a Promise. If you write async function foo() { return 1 } it will actually return a resolved Promise with a value of 1. If your async function has an await, the returned Promise will only resolve when the await has finished, and the rest of the code in the function has run. But notice that the async function returns to the caller immediately. There’s no waiting going on, ever. Now if the caller awaits the async callee like this:

async function callee() {
return 100;
}
async function caller() {
let val=await callee()
await new Promise(resolve => setTimeout(resolve, 100)); # 100 ms
return val+1
}
async function parent() {
let val=await caller()
return val+1

then the same thing happens: the caller returns (immediately) a Promise to its parent that only resolves when its await returns, which only returns when that callee’s await returns, and so on all the way down. There’s essentially a call graph that gets built up so that whenever the runtime loop gets control back, it calls the next thing that’s runnable in that call graph.

Note that in this example, parent calls caller, caller calls callee, which returns its value — all this happens without waiting, synchronously. The first await is the one in caller; that puts the rest of caller’s code into a continuation and returns. Similarly parent puts the rest of its code after the await into a continuation and returns.

If you call an async function without awaiting it, it’ll return its Promise which you can wait on later, or not. If you don’t, the rest of the function after the first await will still get run eventually, whenever the event loop gets around to it. Here’s a nice example of that:

async function f1() { await something long... }
async function f2() { await another long thing... }
async function callParallel() {
let p1 = f1() # a Promise
let p2 = f2() # another Promise
await Promise.all([p1, p2])
}

In this case f1 and f2 both get their continuations set up, they return promises to callParallel, which returns, and then when (eventually) both f1 and f2 resolve, the Promise.all resolves and the last await returns and callParallel’s implicit Promise resolves (but nobody’s checking that).

Some notes

A promise is resolved when its resolve function is called; in this case calling wakeup() calls the promise’s internal resolve() method, which triggers any .then methods on the next tick of the Javascript event loop. Here we use await, but .then(…) would work the same way.

There’s no magic; I/O and timeout promises work the same way. They keep a private registry of functions to call when the I/O event or timeout happens, and those functions call the promise’s resolve() which triggers the .then() or satisfies the await.

By the way, unlike async in python, leaving a pending promise “open” when the process exits is perfectly fine in Javascript, and in fact this demo does that. It exits when there’s no more code to run; the fact that the while loop is still “awaiting” doesn’t keep the process running, because it’s really just some closures stored in a queue. The event loop is empty, so the process exits (assuming it’s in node.js — in a browser it just goes back to waiting for events). In python, this would print an error as the process exits — you’re supposed to clean up all your awaits there. Javascript is more forgiving.

Further reading

Promise docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

Async function spec: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

Async implementation in the Javascript V8 engine — great low-level description of how it works: https://v8.dev/blog/fast-async