Asynchronous JavaScript — Why it matters
Callback, Promises, Async Await. We use these keywords when fetching data from an API, when creating delays or awaiting database calls, etc.
But why? Why do we endorse Asynchronous patterns in the first place?
Let’s find out!
What does Asynchronous mean?
By the definition — it’s a piece of code that will start now and finish sometime in the future. An example of an Asynchronous code can be:
- Network request (call to API)
- Timer (SetTimeout / SetInterval)
- Promise, etc.
By default, JavaScript is a synchronous language, meaning it executes code line by line. However, it starts to behave quite differently when we add Async ingredients into the mix. Let’s see an example of that:
console.log('Frodo')setTimeout(() => console.log('Sam'), 1000)setTimeout(() => console.log('Pippin'), 0)console.log('Merry')
Here we’re using setTimeout to simulate an Async call. Its purpose is to delay the execution of the code within the callback function for a specified period of time.
Since timeouts cause delays, looking at the snippet above you may think that execution will go like this:
- Print “Frodo”
- Wait 1000ms (1 second) and print “Sam”
- Print “Pippin” and finally
- Print “Merry”
But what will happen is this:
[LOG]: "Frodo"[LOG]: "Merry"[LOG]: "Pippin"[LOG]: "Sam" // after waited for whole second
Weird, right?
What actually happened is,
- We print “Frodo”
- Then comes “Sam”. Here JavaScript sees a timeout and schedules it for later
- Then another timeout with “Pippin” comes and reschedule it
- We print “Merry”
- And then scheduled calls appear in order of completion. “Pippin” is printed first as it took less time (0ms) to complete than “Sam” (1000ms).
We can see that these timeouts to schedule execution rather than pausing it.
What is actually happening here is JavaScript is processing Async code in the back while the rest of the code is been executed. How is this possible?
Threads
Thread is a small set of instructions designed to be scheduled and executed by the CPU.
It all starts with a process. A process may have one or more threads. Each thread is designed to carry out a single task from start to finish.
Imagine a typical backend application that uses multithreading.
- We have a web server that is running on the primary thread.
- A request comes to this thread.
- The primary thread spawns another thread and offloads a request to the newly created thread.
- If another request comes in, the primary thread will create another thread and assign the new request to it.
- When one of the requests is finished, the task will be returned to the primary thread that will then return a response to the client. And the previous thread can take on other requests.
So the primary thread can create new threads every time a new request comes in.
Now that we understood why we need multiple threads, you may think that JavaScipt uses the same mechanism to process data. But, here comes the plot twist, JavaScript is single-threaded!
But why then does setTimeout or an API call not block the program execution?
Runtime Environments
Before we answer the previous question, let’s first understand what are runtime environments. A runtime environment is what allows us to write JavaScript everywhere, be it in the browser or on the server. There are two parts we’ll discuss:
- JavaScript Engine
- Web API
JS Engine
The Engine is in charge of converting JavaScript code into machine code. It is what allows us to execute JavaScript on our machines.
There are few engines out there, but the most well-known one is the V8, as it powers Google Chrome, Opera, and Microsoft Edge.
Call Stack (sometimes referred to just as a Stack) is a stack data structure that stores information about the active subroutines of a computer program.
Basically, it’s a space where all of our JS code is placed. Also helps us to keep track of where we are in the code.
How the stack works is each line of code it’s pushed to the stack (bottom-up) and popped off (top-down) when executed (LIFO). The last statement is executed first, then the one before, all the way down until the stack is empty.
We can verify this by running this code in debug mode.
function sum(a,b) {
const result = a + b
return displayResult(result)
}function displayResult(result) {
console.log(result);
}debugger
sum(2, 3)
The code is sorted in reverse order as expected.
It all starts by running the main()
function that is created by the engine and its purpose is to run our code. We don’t writemain()
manually.
Once the call stack is empty, main()
will be popped off as well.
Web API
Web APIs are APIs that are built into the runtime and provide native features that can be used in a JavaScript app. These include:
- Document Object Model (Browser API)
- Fetch API (Browser / Server API)
- File System (Server API)
- Workers API (Browser / Server API)
- etc.
Because these are not built into the language, but rather are part of the runtime, they’re not been processed by the JavaScript thread.
Going back to the question of why the JavaScript thread does not block when we execute an Asynchronous request because such a request is handled by a scheduler (the API) instead of the call stack (main thread).
Moreover, when JavaScript sees the Asynchronous call,
- it will be moved from the call stack,
- get processed by the API,
- put in a Task Queue (sometimes referred to as Callback Queue), where all the other Asynchronous calls are been held until the call stack (main thread) is empty
- then get pushed to call stack once the request is ready to execute and popped off the stack once it’s done
(We will see an example of this below)
Web APIs use additional set threads to process Events, API requests, Timeouts, etc., without blocking the main execution context.
So the whole thing is been processed by the threads sitting inside the runtime, outside the main thread, or outside of core JavaScript if you will.
This is why when we click a button or make a network call in our frontend application, the app is still running and the screen isn’t frozen while the request is being processed. It’s running in the back, allowing our app to stay interactive.
Synchronous tasks remain on the main thread (call stack) and are executed first. Asynchronous ones go through a different process (that we’ll get to in a minute) and then get pushed to the stack and they’re executed on the thread.
This is why we see “Frodo” and “Merry” printing first, while “Sam” and “Pippin” appear later.
console.log('Frodo') // syncsetTimeout(() => console.log('Sam'), 1000) // asyncsetTimeout(() => console.log('Pippin'), 0) // asyncconsole.log('Merry') // sync
This scheduling of tasks is happening on the backend side of JavaScript as well. That’s why things like:
- Network calls (REST API)
- Timeouts
- Streams
- I/O operations (reading/writing into files/databases)
- Pending OS tasks (listen to a port)
don’t block the execution on the main thread. Hence, why Node.js was advertised as Non-Blocking I/O.
In the case of Node.js, Web APIs are handled by Libuv library. Libuv is written in C, which is a multi-threaded language, which means that Node.js uses threads under the hood.
In the case of Deno, it uses the Rust library called Tokio.
And everything else, like loops, conditions, declarations, etc, is executed on the main JavaScript thread.
Obviously, there is a lot going on here, so you might be wondering how is the whole process being managed?
The Event Loop
On the first run of our application, JavaScript creates a single thread, a single call stack, and a single instance of the Event Loop. The Event Loop is responsible for executing the code, collecting and processing events, and executing queued sub-tasks.
Phases of the Event Loop
We can think of an Event Loop as a loop that is continuously spinning and running different operations in different phases:
- Timers: This phase executes callbacks scheduled by
setTimeout()
andsetInterval()
.
2. Pending Callbacks: Executes I/O callbacks deferred to the next loop iteration.
3. Idle, Prepare: only used internally.
4. Poll: Retrieve new I/O events and execute I/O-related Callbacks, e.g.
- API handler app.get('/', (req, res) => {})
- File System fs.readFile()
Almost all Callbacks are executed in this phase, except for Timers and Checks.
5. Check: setImmediate()
callbacks are invoked here.
6. Close Callbacks, e.g. socket.on('close', ...)
.
We can visualize the Event Loop like this,
Every time a new request comes in, the Event Loop will determine to which phase it belongs and move it there. This is important to know as these phases execute in order.
So if we have a Timer (setTimeout()
) and I/O Callback (fs.readFile()
), even if they take the same time to execute, Timer will execute first.
Note: One cycle (spin) of the Event Loop is called a Tick.
Practical Examples
Let’s once again look back at our starting example and try to understand how the whole thing is working behind the scenes.
- We kick things off with Frodo, Sam, Pippin, and Merry appearing on the stack.
- We then separate Asynchronous tasks and push them to Web API
- After that we execute the console logs (Frodo and Merry), then we call
main()
- Once the call stack is empty, we begin processing the Web APIs. The ‘Pippin’ timeout has a short delay, so it is placed in the queue rather quickly
- Now the Event Loop kicks in. It checks if there is anything in the Task Queue. If the stack is empty, it takes the first thing in the queue and pushes it to the stack
- Then we execute the first timeout that arrived at the stack. But then we get to the very interesting point.
The call stack is empty, but the ‘Sam’ timeout is still been processed.
The Event Loop will keep on spinning, checking the stack, and queue back and forth whilst waiting for a new task to pop in the queue
- Finally, the second timeout arrives at the queue
- And the Event Loop puts it back into the stack, where it’s executed and then removed
But, what would happen if we had two timeouts and both had the same delay?
- Both would be pushed to the Web API.
- Then put into a queue in the order completion
- Then one would appear in the call stack and execute. Then the other.
Basically, tasks execute in the order they were queued.
This is happening because the Event Loop can only push one thing at the time into the stack.
Macrotasks and Microtasks
So far we mostly discussed Callbacks, but what about Promises? In which phase are they executed?
ECMAScript 2015 introduced the concept of the Job Queue, which is used by Promises, queueMicrotask, and the Mutation Observer API. It’s a way to execute the result of an async function as soon as possible, rather than being put at the end of the call stack.
Any task in Job Queue (Microtask) will have greater priority than the Callback Queue (Macrotask) task.
Microtasks can be executed in each phase of the Event Loop. In fact, they have precedence.
setTimeout(() => console.log('Callback executed'), 0)Promise.resolve().then(() => console.log('Promise executed'))
If we have a Callback and Promise in the same block, Promise will execute before Callback.
[LOG]: "Promise executed"[LOG]: "Callback executed"
The same is true for process.nextTick()
function in Node.js that takes precedence over Promises. This function is called on every tick (spin) of the Event Loop.
Promise.resolve().then(() => console.log('Promise logged'))process.nextTick(() => {
console.log('Next tick logged')
});//[LOG]: "Next tick logged"[LOG]: "Promise logged"
It’s the way we can tell the JavaScript engine to process a function asynchronously as soon as possible.
Don’t Block the Event Loop
The golden rule in JavaScript says “Don’t Block the Event Loop”.
This essentially suggests we use Asynchronous programming whenever possible to have better-performing applications.
Any synchronous task will block the Event Loop, as Event Loop cannot run until the call stack is empty. But did you know that Promises can block the Event Loop as well?
A Promise, being a Microtask, is capable of en-queuing other microtasks. This means that Promises will run one after another, completely disregarding operations in other phases.
The Event Loop does not move to the next task outside of the microtask queue until all the tasks inside the micro task queue are completed.
setTimeout(() => { console.log('Timeout') }, 0)Promise.resolve().then(() => { console.log('Promise 1') })Promise.resolve().then(() => { console.log('Promise 2') })Promise.resolve().then(() => { console.log('Promise 3') })//[LOG]: "Promise 1"[LOG]: "Promise 2"[LOG]: "Promise 3"[LOG]: "Timeout"
This is a problem, because we may want to read from files or perform other I/O operations that are managed through Callbacks, but due to Promise having higher priority, it will block the Event Loop, and we will never get to the point to begin processing other tasks.
To solve this, we can wrap Callback code into a Promise or search for API alternatives that use Promises, e.g.
const fs = require('fs').promises;fs.readFile('/file.txt')
.then((result) => console.log(result))
.catch((err) => console.error(err));
Or avoid using microtasks where they might create a bottleneck.
Nested Operations
Now we know that if we put Promise and Callback under the same umbrella, Promise will have precedence over a Callback. To really put it to bed, let’s see this again with nested code.
setTimeout(() => {
Promise.resolve().then(() => console.log('Promise 1'))
setTimeout(() => {
console.log('Timeout 1')
}, 0)
console.log('Logger 1')
}, 1000)setTimeout(() => {
setTimeout(() => {
console.log('Timeout 2')
}, 0)
Promise.resolve().then(() => console.log('Promise 2'))
console.log('Logger 2')
}, 1000)Promise.resolve().then(() => console.log('Promise 0'))
In this case, the output will be:
[LOG]: "Promise 0"[LOG]: "Logger 1"[LOG]: "Promise 1"[LOG]: "Logger 2"[LOG]: "Promise 2"[LOG]: "Timeout 1"[LOG]: "Timeout 2"
- Starting the app, there aren’t any blocking tasks, so we call the first Promise, as it has priority over timeouts.
- Then we call the first timeout.
The code inside is not immediately accessible due to delay (1000ms), so the Event Loop goes to the next timeout. - The second timeout also has a delay.
What the Event Loop is going to do is make a few spins (ticks) until either timer expires. - Upon completion, the Event Loop looks inside the first timeout and spots the logger, microtask, and macrotask. Logger is executed first, followed by a Promise (microtask) within the same scope.
- On the same level, right outside the scope of the first wrapper, we have another timeout, that happens to also have a logger, micro, and macrotask.
- The second Logger is printed, followed by a second microtask.
- Finally, we get to macrotasks as they have the least priority. So we call both of them.
Queue Microtask
Queue Microtask is a new browser API that runs after the current task has completed its work and when there is no other code waiting to be run before control of the execution context is returned to the browser’s Event Loop.
Anything we put inside will be treated with priority.
queueMicrotask(() => {
console.log('Hello World');
});
It’s similar to what we were doing using setTimeout:
setTimeout(() => {
console.log('Hello World');
}, 0);
The only difference is that queueMicrotask will execute ahead due to the microtask queue.
Demystifying Async Await
The Async Await feature was added to JavaScript with ECMAScript 2017 as a way to better handle Promises. But there is a common misunderstanding about it.
There is a belief that when you put an async keyword in front of a function call in JavaScript, you turn an operation that is normally Synchronous into Asynchronous. Although that isn’t wrong, it certainly isn’t a game changer as it appears.
Let me show you what I mean.
In C# API calls are synchronous by default, which isn’t much of a problem as C# is multithreaded.
public List<UserClass> GetUsers(int userId)
{
var result = _userContext.GetUsers()
.Where(...)
.ToList();
return result;
}
We can convert this to the Async variant by using the Task class.
public async Task<List<UserClass>> GetUsers(int userId)
{
var result = await _userContext.GetUsers()
.Where(...)
.ToListAsync();
return result.Value;
}
One would think that JavaScript functions the same, but we’ve just learned that JavaScript can run Asynchronous tasks in Callbacks (macrotasks) that have been part of the language long before Async Await.
In fact, JavaScript/Node.js will always try to execute I/O operations Asynchronously unless we specifically say otherwise.
const fs = require('fs');
fs.readFile( path, options ) // Asynchronousfs.readFileSync( path, options ) // Synchronous
So what’s the purpose of Async Await in JavaScript then?
Async Await is wrapper for Promises — a way to handle them in a more readable fashion and easier to maintain.
Native use of Promises
function greeting() {
const greetingPromise = Promise.resolve('Hello World')
greetingPromise.then(greet => console.log(greet))
}greeting();
Async Await alternative
async function greeting() {
const greetingPromise = Promise.resolve('Hello World')
const greet = await greetingPromise;
console.log(greet);
}greeting();
The way it works is that we put await keyword in front of the pending Promise. This works like a Promise.then()
call, except that it does not require a Callback to get the output (greet).
Let’s quickly go over all the steps:
- To make use of await inside a function, it must be declared with the async keyword
- The await keyword can only be put in front of an expression that evaluates to a Promise
- The await keyword pauses the execution of the async function until the Promise is settled (resolved or rejected)
When this happens, the entire await expression evaluates to the result value of the Promise, and then the execution of the async function resumes.
Even though Async Await makes our code look synchronous, keep in mind that this is still a wrapper for Promise, which is async. However, there is a slight difference — the Pause.
function greetingWithPromises() {
console.log('Logger 1')
const greetingPromise = Promise.resolve('Hello World')
greetingPromise.then(greet => console.log(greet))
console.log('Logger 2')
}greetingWithPromises();//
[LOG]: "Logger 1"[LOG]: "Logger 2"[LOG]: "Hello World"
Let’s compare this to Async Await.
async function greetingAsyncAwait() {
console.log('Logger 1')
const greetingPromise = Promise.resolve('Hello World')
const greet = await greetingPromise;
console.log(greet);
console.log('Logger 2')
}greetingAsyncAwait();//
[LOG]: "Logger 1"[LOG]: "Hello World"[LOG]: "Logger 2"
Because the await expression causes async function execution to pause until a Promise is settled, await won’t let loggers to complete before continuing execution. So in essence, Async Await executes before Promise.then()
as it does not need to wait for the call stack to be empty.
The final gotcha with async functions in JavaScript — they always return Promises, regardless of what they contain.
The output can then be handled via another await or Promise.then()
.
Async Await has found its way into other branches.
File System
Just like with this reading data from file example.
const fs = require('fs').promises;
const output = await fs.readFile( path, options )
API handlers (Express.js):
Before
app.get('/', (req, res) => { // call to database .then( ... ) })
After
app.get('/', async (req, res) => { // await call to database })
Summary
JavaScript delegates Asynchronous tasks from the main thread to the Web API. That’s why async requests don’t block the program execution.
The same applies to the backend side of JS. DNS requests are asynchronous. When a new request comes in, it’s delegated to the Web API. DB calls (I/O operations) are also non-blocking, so the main thread is free to take other requests while Liuv threads are processing I/O requests in the back.
The Event Loop manages the scheduling of tasks. It consists of multiple phases, each in charge of processing different operations.
We have microtask and macrotask queues. Each has a series of APIs associated with them.
Microtasks have priority, and thus are executed beforehand, but block the Event Loop.
Async Await is a wrapper for Promise functions that let us write async code in a synchronous manner.
Pros & Cons
Now let’s go over the ups and downs of using Async code.
Cons
Speed
Async calls are actually slower than synchronous ones because it takes time to process them, as well as take a round trip through the Event Loop.
Development
Writing and debugging Async code is hard. You may have heard of a term called Callback-Hell. It’s a series of deeply nested functions that are dependent on one another and are very hard to read and maintain.
const fs = require('fs');
const data = 'Hello World'; setTimeout() => {
fs.readFile(fileWithData, 'utf8', (err, response) => {
if (err) return console.log(err);
fs.writeFile(data, response, (err) => {
if(err) return console.log(err);
console.log('Done!');
});
});
}, 0);
There are downsides to newer APIs as well.
Since functions marked with the async keyword return a Promise, if we have a series of nested function calls, each needs to be marked as async to be able to use the await keyword within. Otherwise, we’re prone to use Promises.
function changeGear() {
return Promise.resolve('Fifth Gear');
}async function hitTheGas() {
return await changeGear();
}async function drive() {
const data = await hitTheGas();
console.log(data); // Fifth Gear
}drive();
Synchrounous Operations
We know that synchronous operations are executed on the main thread. Since JavaScript uses only a single thread to handle these operations, our app is going to run line by line and when it encounters a loop, it will pause.
That’s why JavaScript is not good for long-running blocking operations, like crunching numbers. Since all of its blocking execution is on a single thread, long-running operations cannot be delegated to other threads, meaning that our app will wait and block everything else.
This is probably the biggest downside of JavaScript, but there are some feasible solutions recently added to the language like Web Workers API.
Pros
API Improvements
Async API is constantly been improved with new versions of ECMAScript. It started with Callbacks, then moved to Promises, and then Async Await.
The Promise API recently brought new additions likePromise.any()
, Promise.race()
and Promise.all()
.
Then there is also Top-Level Await.
Extended Toolkit
JavaScript comes with a list of tools to work with Asynchronous data, native ones like:
- XMLHttpRequest
- AJAX
- Fetch API
as well as third-party ones:
- Axios
- Rx.js
- Async.js
Dynamic Module Imports
Instead of having imports on top of the file, the modules can be imported dynamically. The idea behind this is to use Asynchronous calls to import modules (files) after a certain action occurs, like a button click.
Non-Blocking Execution
As we established so far, Async code is processed outside the main thread and thus does not block the thread.
Nevertheless, now we know why it’s important to use the Async patterns in JavaScript whenever possible. Many popular frameworks and libraries on NPM use Async patterns automatically, although some of the older ones still work only with Callbacks. So it’s a both hit and miss.
Workers API
Before wrapping up let’s say a word or two about the new JavaScript Workers API. Web Workers make it possible to run a script operation in a background thread separate from the main execution thread of a JavaScript application.
Its purpose is to separate sync tasks into smaller workers, so that long-running operations (such as loops) are not blocking the main thread.
It works just as we’d expect. The main JavaScript thread can send data to the worker threads. Each worker processes a task sent by the main thread and returns it upon completion.
Node.js also has its own variant with the use of worker threads built-in module.
However, these are not efficient for async tasks. The standard (Event Loop) way is much better to handle async tasks.
Better Thread Pool
Even with all the powers of the runtime, we are still bound by the thread pool size. The Libuv library allows us to process only a few I/O operations at once, due to Libuv having up to 4 threads in the thread pool.
We can increase this number to up to 1024 threads by setting the following environment variable on application startup:
UV_THREADPOOL_SIZE = x
But keep in mind that bigger isn’t always better. Try to find the optimal thread pool size according to the performance of your machine. The usual approach is to set it to equal the number of logical cores of your PC.
We can verify that by importing the OS module (that is native to Node.js), then call the os.cpus()
(that returns an array) and then print the length of it:
const os = require('os')
console.log(os.cpus().length) // e.g. 16
Then we can set the thread pool size, either in the .env file or dynamically (in our main JS file):
process.env.UV_THREADPOOL_SIZE = os.cpus().length
Final Words
To tie things with a bow, JavaScript leverages Async code to execute away from the main thread. This pattern is great as it does not block the execution of the program, but it comes with some downsides as well.
Overall, it’s an amazing feature that makes JS stand out in the crowd.
In part 2 we’ll dive deeper into practical examples with Fetch API, Observables, Handling errors, and more.
Useful Resources: