Promises, Async, and Beyond: Control Over Time

Piotr Stanief explains how promises and async/await work in JavaScript, covering the event loop, microtasks, and a working promise implementation. Learn what actually happens when your code waits for something to finish.
So hello everyone. Welcome to my talk. My name is Piotr Stanief and today you'll hear from me about promises and asynchronous programming in JavaScript, and this talk is one of two parts. The other part will be hosted by Duncan Water on Thursday and I encourage you to join that part as well. It will touch on a similar subject, but we'll focus on a completely different area of handling errors in Asynchronous and I would love to hear from you. Also, after the talk and even before the talk starts, please feel free to hop into the channel and let everyone know what made you enroll in this particular talk and what do you hope to learn from it. So let me tell you briefly a little bit about myself so that you know who will be talking to you for the next half an hour. I'm a senior software engineer and a member of Admin Governance team. I've joined HubSpot on April 2021, a day before my birthday and to be fair, I've made myself a really nice gift joining the company. I'm having a great time here, so let's have a brief look at what we'll talk about today. So I'll start with the basics, discussing what is asynchronous code and how it works. Next we'll have a look at a few practical examples which will help you write asynchronous code efficiently. And the first two parts will be a little bit simpler, but don't worry if you're a little bit more advanced. Next we will be making a deep dive into the JavaScript engine on the example of V8 engine and we'll see how it handles asynchrony. And finally we will learn how promises work by implementing them. I will try to implement a home baked promise class that is an approximation of what the browser does. So before we even start our journey, we need to agree on a few basic terms so that it's easier to navigate this landscape. So for starters, you might be wondering what does it mean for something to be asynchronous? So let me try to explain. Typically you expect your code to be executed roughly line by line in the order of execution. And we talk about asynchrony when a programming language allows you to execute a function out of order in response to some future events like user interaction or time balancing, and this creates a perceived gap between code that runs now and code that is scheduled to run later. And this example illustrates asynchronous behavior. Crucially, it shows two different mechanisms behind asynchronous JavaScript engine. So the code gets executed line by line. So we first in the first example we initially output a synchronous muffin into the console. Subsequently we call the settimeout function, passing to it a function that doesn't get executed straight away. This function will only run after a delay, so we say that it is asynchronous. Next we create an immediately resolved promise and schedule a callback for it using then method for this callback to be called later on and it will output asynchronous microglass candy. However, callbacks scheduled with the promised N are always asynchronous, so this doesn't get printed to the console immediately. And finally we get to the last statement outputting synchronous avocado. And now it's a time when the script has finished running so we can start executing our asynchronous code. So first we will run all micro tasks which are scheduled with promises, and this is when the asynchronous microtask candy gets put into the console. And next we will run all asynchronous tasks or macro tasks as some call them. And they might be for instance created with set timeouts or with some events like mouse clicks. But we'll get back to that in a moment in more details. So you might be wondering now how your web browser actually achieves asynchronous behavior. So to do this it relies on a concept of an event loop. The browser will run over the event loop in cycles, processing various steps as it goes. So one key step in eventloop is processing tasks. Now you might wonder so what is a task? And there are a few key tasks. So namely your JavaScript code that is loaded with script tag as well as callback schedule with settimeout or set interval APIs. These are all tasks. And another example of a task is dumb event callbacks. These are usually the likes of click or mouse click or keyboard typing. So in each run of the event loop, tasks will be taken from the queue and executed. So there is one task per run, and if a task results in scheduling another task, it won't be executed until another run of the event loop. And apart from that there is another mechanism that allows for asynchrony. The mechanism is called microtasks and they are executed at the end of a single event loop iteration. So they are the mechanism that is used by promises and are slightly different from past in that they are running until the queue is empty. So if a micro task schedules another Micro task, it will get executed as well in the same run of event loop. And finally, there are some other steps that are part of the event loop, like functions scheduled with request, animation, frame, and also all that work that is actually required to render your page. So they are also running at the end and they are only rerun when needed, so they might not be part of each iteration of the event loop. So as we navigate this landscape, it might be worthwhile having a look at a few practical examples of how to work with your asynchronous code. So I mentioned earlier that promises are a way to write asynchronous code and to schedule a micro task. But however, the mere act of creating a promise instance does not involve creating a micro task. So in the first example, the constructor of promise is called immediately and the action doesn't get queued up as a microtask. In this example, the output printed in the console is in the order it appears in the code. So it's worth remembering that where you to put an intensive task into the constructor, it may block the code execution. So the output in the console from this example would be before inside, after. However, in the latter example the output would be before after inside, since the promised callbacks are always scheduled as micro tasks and those are asynchronous. And let's have a look at these two examples. So they both present an infinite loop. And in the first case, the page this code runs on won't be blocked. Every time the FN function runs, it schedules a new task using the settimeout function. However, tasks are only queued up and they run once every event loops cycle, so it doesn't block your page. There is still this rerendering, restyling, layouting time that is running at the end of each step of event loop. However, in the latter example, each run of the function FN schedules another micro task. And as we talked about before, micro tasks are different from tasks. So if microtask puts another microtask on the queue, the newly added one will also run in the same step of the event loop. So the micro task queue has to be exhausted before starting another step of the loop. So in this case your page would be completely frozen. You wouldn't be able to click a button on your page. It wouldn't look like if you were pressing that, it will remain still. And in the former example you would still see some animation of the button clicking it, it would be able to schedule some tasks. Another useful aspect of utilizing promises is if promises callback returns a promise, it will get exposed by then. Because of that, the two snippets that you see in your screen are equivalent, except that the latter is typically more readable as it has less nesting. So as we have learned earlier, the promise constructor per se is synchronous, so we don't frequently use it to get a promise to do some asynchronous work, and it's far often that we use some native web API that returns a ready promised object for us rather than creating it ourselves. However, one of the use cases for the constructor is to promisify an existing API like settimeout, something that relies on callbacks like in this example. So it's possible to expose the resolve function that gets passed from within the constructor, for instance by passing it to another function as a callback. Another recipe that is worth adding to your bag of tricks is deferred objects. It is similar to the previous example. So in this snippet of code you can see example implementation of deferred objects. There are various implementations possible and it allows you to create pending promise. It will be forever pending until you resolve it and you can control it. You can control when it gets fulfilled outside of this function that you pass to the promise constructor. It's quite useful pattern that allows you to encapsulate powerful logic without having the constructor run for many many lines of code, and it can oftentimes improve the eligibility of the code. And JavaScript for a couple of years now has additional syntax that simplifies usage of promises and allows you to avoid so called callback hell. And it frequently since it has changed from the past, it frequently is more performant than using actually native promises, and it comprises of two parts. Adding async keyword before the function declaration, be it keyword function or arrow function, it will always make it return a promise. If the function would return a primitive value like in this example, the value will be wrapped into an immediately resolved promise. If the return value was a promise, the promise would get returned. So it's worth noting. However, if your async function always returns a promise and there is no await, you probably don't need the async keyword. But the async keyword also enables another feature which is the use of await keyword. The await keyword will pause execution of the function until a promise that follows gets resolved. It works exactly the same as using then on promises, so pretty much the rest of the function will become a microtask as soon as the awaited promise gets Resolved. It's worth noting here again that you can return a promise from an async function and it will get exposed as it is. So in many cases, if you see return await together, quite likely await is redundant. Although there are two nuances to remember when using return await in your code. So firstly, it will have a slightly different stack trace when you don't use await after returning, so you may want to evaluate which one is more suitable for you. And secondly, it behaves slightly differently when it's wrapped in a try catch statement block. So if you think about it carefully, it will make sense, but it might be not immediately obvious. So in the first example we simply call maybe throw function and return whatever it returns the risk, no error thrown, since maybe throw returns a rejected promise and it's the await keyword that throws. So in the second example, the await happens before returning the result, and it is, as I said, the await that throws the error. So when it's followed by the rejected promise, it will throw an error that is code actually within the function. And now that we have some grasp of how we use promises, let's make a deep dive into how they work in the web browser and implement an approximation of a promise class. So before we analyze an example in depth, let's actually have a look at what is a call stack. So call stack keeps track of all the functions that get executed in your code, so that it knows where to jump back when you return from a function. This is what V8 engine would do. So initially there is one frame added for your module for your task that is the context of the entire entire file, probably it's called a mainframe. So so we start looking at the code first by scanning the declaration that comes at first. So we don't have any invocation of a function, so we don't add anything to the call stack. We just add the sum to the execution context. Then we have another function declaration. It's also not added to the call stack for the same reason, but then we get to actually invoke the 5 and try to get the result. So as we call the function, we'll jump to the declaration, start executing the function, and it's added to the call stack that has its own frame. It was called with arguments. There is one argument three and we remember which line it was called from its line number nine so that we can jump back later to it when we are finished. Next we execute the next line. We have another function invocation we cannot return just yet because we don't know what is sum of A&5. So we add sum to the call stack and we jump to it and start executing it. And sum was called with arguments three and five. So now we get to return statement. It has an expression a b. So so we just add three, five, that's eight. And now we can jump back to where we started because that's a return statement. So we will pop this frame from the call stack. The result was eight. So we put that in place of the sum a five. And now we have the result that it gets assigned to a variable that's 8. And the next line is console log call. That's also briefly added to the call stack with argument eight. And that gets printed and we have finished. That's all for this. So this is roughly how a call stack works, so that you have an idea more or less what happens when you run when you run your code in the browser. So let's have another look at the same example that we had at the beginning and we'll try to understand why the individual console logs were printed out in the console in the order that we talked about. So first we just load the mainframe for the code and we call the console log function with a synchronous muffin. And this calls so called runtime API for logging. That's one of the web APIs that browser provides to the engine. The engine itself doesn't know necessarily about writing to console. As this function has finished running, we printed out something to the console and we go next to the settimeout. So we call settimeout with two arguments, some anonymous function and zero, and we call the runtime API timer API that will run this function for us. But it doesn't happen immediately. So the runtime API puts a task onto the queue for us after zero milliseconds have passed, because that's what we wanted. But we don't execute this immediately because we still haven't finished our first task, which is this nine lines long file. So we proceed and go to the promise resolve. It gets put onto the call stack for a brief moment, but all it does it returns an immediately resolved promise for us. So we leave that quickly and go to then function. So then gets put into the call stack as its own frame and it schedules a microtask for us, which is the anonymous function that we pass to it. So we still don't output anything to the console. And we finished running that. So up until now we only had a console log called from within this task once with asynchronous mapping and that's the second time where we call console log with synchronous avocado. So we reached the end of this task and now we will continue pretending we are the V8 engine. So next we said that microtask Q at the end of the iteration will have to be emptied. So we take the anonymous frame, we jump to where it was, schedule from where it was declared, and it is called without any arguments. But we put a console log with asynchronous microtask candy to the console, and as this finishes running, we can jump back to where we were. We already emptied the microtask queue and there is now the task queue that we can execute in the next task take of event loop. So the task that we have now is to execute another anonymous function. It calls console log with asynchronous task cake. And when this finishes running, we jump back and we finish processing our code, our module. So this is roughly how the V8 engine will deal with asynchronous CD. So the next thing is I will try to implement an approximation of promise class. From what I know, it is implemented in the browser rather than in JavaScript, so it's only an approximation, but nonetheless I will try to do this. It is less than about 100 lines of code, so it's not too complicated. So first we'll write a class my promise. And one of the things that you might remember from the slides is that there is a constructor that takes some action. So that's the function that takes resolve and reject. So for starters, we want to say that promise has some state and that when you create a promise, it is pending. And what we do is we call this action in a try catch so that we can handle any error inside the constructing function. And action, as you remember, gets called with resolve and reject and they come from within the promise. So let's call them do reject with some calls and do result with a value. And instead of making them methods, I will actually make them fields so that I don't have to think about this. Okay, so we pass them to the action and we also want to handle the case when the action actually has failed thrown an error. So now you might be wondering, so how do they actually work? So I'd say the way it works is if the promise isn't pending, we don't do anything at all. So that's the starting point, because you can't resolve or reject again, a promise that was once fulfilled. Next we want to change the state, probably because if it gets rejected we should update it to reflect that. And we want to keep track of the cause and the value as well. And for now, that's all that we can do in this direction. It would be kind of enough. But we also want to be able to subscribe to the result of a promise. That's the whole point. So we will start implementing then method. So then takes two callbacks. One of them is colon value or the other one is onreject. So they will be called whenever the promise is actually resolved or rejected. So just so that I don't have to be kind of worried about what this points to, I will assign the state to auxiliary variable. But now one of the things that you have to remember about then is that it always returns a new promise. And this promise will get resolved when you know, your callback gets called. So we create a new promise here, and let's see what should happen in the interface. So I can tell you one thing for sure that I've checked that. And if you don't pass on value, and if you don't pass on reject, then what you actually want to do is to resolve the promise with undefined. So then we'll return a new promise that gets resolved with undefined value. And then if you, if you pass actually something and if the promise is pending, we actually want to kind of subscribe to what has happened here. Like what was the cause, what was the value. So for that I will add an array to the state that will be called subscribers, and I will add subscribers to this list as they arrive. So this will be an object because you can, you know, you can subscribe either to value or the rejection or both. So let's say that we add an object like this onresolve and onreject with cause reject onreject calls, right? So you want to kind of pass the value to your callback and then whatever it returns, we will resolve this promise with. And the same with reject. Like if your promise, the original promise gets rejected, you want to pass the cause through the onreject and then reject the new promise with whatever value your callback returns. But there is a couple of nuances that we want to take care of. So basically your callback on value or onreject may throw an error. So we need to handle this somehow. And then there is also this case when onvalue returns a promise and then you want to expose it. That was also in one of the slides. So to do this I will write myself one helper is then a bull so that I can say Whether what I'm working with is something that looks like a promise. And I will write myself two helper functions, safe resolve and safe reject. And they will actually handle like proper rejecting and resolving for me. So the safe resolve will get result reject your callback and the actual value the callback is from then. And basically the idea is if there is no callback, we can just resolve with that value. And in another case we will run this in try catch block. So the results of your callback running will be assigned to a variable and we will check if that's a promise as well. If that's a promise, we can actually use it and expose that value from this promise. But in the other case we just simply resolve the value and to handle this error, we will just reject with the error. So the results and rejects, they are coming from the newly created promise here. So let me change this to safe result and then safe reject is similar but maybe shorter. So we say that if there is no callback, we just simply reject with the cause and then we try to return with the codes if there is another error we just rejected with that error. Okay, so we have the subscribing mechanism. It was a little bit complicated, but it had to handle a few edge cases. But now it will be easier. So here for the asynchrony. So the key thing is we start in the reject and resolve. In the actual step for rejecting and resolving, we should start calling the subscribers and we should do this in a micro task. So one way to do this is let's take the subscribers out. And for each subscriber that's on resolve, we want to call it with the value, but that's not a microtask. To schedule a microtask, you can use Q my API and you can pass a function to it. So now that becomes a microtask and it will work in a similar fashion for rejects. Actually, I will default this as well just to be safe. This time we reject with the calls. And there is also one interesting thing that we can handle here, the case of unhandled promise rejection. So if your promise actually fails but there are no subscribers, we can rethrow the calls and that's unhandled promise rejection. And finally we are just about to finish. But there are two more cases that we need to handle, which is what if you call then when the promise was already resolved or rejected. So that's relatively easy. You have to just call a micro task which will do the resolving for you of the newly created promise and for the rejection it will work similarly, and that's roughly it. The catch is really just an alias for all them. And we also have two static methods, so for brevity I will just show how they might look like. So resolve basically creates a promise and resolves it immediately, rejects it immediately, and catch basically calls then with first callback being undefined and it does safe resolving. And that's all when it comes to implementing a promise. You will see the code in the slides afterwards afterwards as well. So you can test this out. Maybe there are some bugs. I only checked a few interesting places. So thank you for joining me on this journey. And the slides are on my website epicweek 2023 gotstein pl and this will be also available with other slide decks.










