DEV Community

Cover image for Async the Programming: How Concurrency Works in JavaScript
Kinanee Samson
Kinanee Samson

Posted on

Async the Programming: How Concurrency Works in JavaScript

Picture this: you are trying to make a network request; let's say you are building the next big AI startup, but you don't have the funds and technical knowledge required for LLMs, and you shamelessly use the OpenAI API because that's what a normal developer would do. You give barely any attention to how the request is made; yes, you write the code, and I'm betting all the money I have, which is none, that you are using async/await.

We are all accustomed to the syntax of async/await or Promises, but rarely do we fully understand how this simple yet powerful feature enables us to build non-blocking applications. One of the most satisfying parts of working with JavaScript is the ability to handle asynchronous tasks. Some of you might have a basic understanding of how asynchronous programming works in JavaScript, but on a deeper level, do you fundamentally understand how JavaScript, which is inherently synchronous and will run on a single thread executing code line by line, will allow you to write code that can be executed asynchronously

In today's post, I will take my time to explain in detail what asynchronous programming is and how it is implemented in JavaScript. If you prefer watching video content, then you can watch the video on YouTube.

What is asynchronous programming?

Asynchronous programming is a technique that allows a program to execute tasks without blocking the main thread. Instead of waiting for a time-consuming task to complete, the program continues running other operations. When the task finishes, its result is processed. This approach is essential for efficient I/O operations (e.g., network requests, file reading) and scalable systems. Asynchronous programming in JavaScript is achieved via callbacks, promises, and the event loop, and is single-threaded by design.

Image description

We have certain terms in that explanation that we need to split apart to understand more of what's happening, first on the CPU level and then work our way back up to the software level.

What is a thread?

A thread is the smallest unit of execution within a process. (Hold on now, we've got another term, process, which I will further expand on shortly; for now, it's back to threads). A thread is a sequence of programmed instructions that can be managed independently by an operating system scheduler (this is another term that I will also touch on shortly). Threads enable concurrent execution within a single program, allowing multiple tasks to make progress simultaneously.

Image description

What is a process?

A process is an independent instance of a running program with its dedicated resources, managed by the operating system (OS). A process represents a program in execution, complete with code, data, memory space, and system state.

Image description

Key Characteristics Of Processes

  • One process cannot directly access another's memory.
  • The crash of one process doesn’t affect others (unless designed to communicate).
  • Processes can spawn child processes (e.g., a terminal launching a text editor).

Each process has a Process Control Block (PCB) stored in kernel memory:

  • Process ID (PID): Unique identifier for each process (e.g., 4043).
  • Program Counter: Next instruction to execute.
  • Memory Limits: Code, data, heap, and stack boundaries associated with that process.
  • I/O Status: Open files, network connections.
  • Permissions: User/group privileges.

Threads and processes are fundamental units of execution managed by an operating system, but they serve different roles and interact in specific ways. Here's a clear breakdown of their relationship:

A process is a container for resources (memory, files, CPU time), while threads are workers inside the container that execute tasks. Every process has at least one thread (the main thread). A process can spawn multiple threads to work concurrently.

Threads share the process's resources (memory, files), but each thread runs independently with its own:

  • Stack (local variables, function calls).
  • Program counter (next instruction).
  • Register state (CPU context)

Process = A factory (with its building, supplies, and budget).
Threads = Workers in the factory (share the factory's resources but perform separate tasks).

Key Differences

  • Processes are heavyweight (slow; need OS setup), while threads are lightweight (fast; share existing resources).
  • Processes are crash-safe (fail independently), while a single thread crash kills an entire process.
  • Threads are complex and require Inter-Process Communication (IPC), while threads are simple with direct shared memory access.

How They Interact

  • There is a single-threaded process, where one thread handles all tasks (e.g., a simple CLI tool). In this situation, blocking one task blocks the entire process.

Image description

  • Multi-Threaded Process where multiple threads run concurrently within one process: Example: A web browser. Thread 1: Downloads images, Thread 2: Renders the page, Thread 3: Handles user clicks; threads share heap memory but not stack memory.

Image description

  • Multi-process applications with separate processes run independently (e.g., Chrome uses multiple processes for tabs). Communications via IPC (pipes, sockets, shared memory).

Image description

Operating system scheduler
The OS scheduler is a core component of an operating system that decides which process/thread runs next on the CPU. It ensures efficient resource use, prevents starvation, and balances responsiveness across all tasks.

Image description

Why do we need an OS scheduler?
It is important for

  • CPU Utilization: Keeps the CPU busy (e.g., switches to a ready task when one waits for I/O).
  • Fairness: Prevents any single task from monopolizing resources.
  • Throughput: Maximizes tasks completed per time unit.

Each browser tab is typically its process in modern browsers (Chrome, Edge, Firefox), but a single tab can also use multiple processes for advanced isolation.
JavaScript runs on threads within these processes, with one main thread per renderer process that handles DOM, events, and your JS (single-threaded!). Let's assume that a browser tab is using a single process for its execution. JavaScript is fundamentally single-threaded, meaning it uses only one main thread for execution within a browser tab's process. T

This design simplifies programming but requires careful handling of blocking operations. Here's how synchronous execution works with threads and processes:

  • Each browser tab runs in its renderer process; within this process, JavaScript executes on a single main thread. All tasks (DOM updates, event handling, JavaScript code) run sequentially on this thread.
  • JavaScript executes code in sequence without interruption
  • Each operation must finish before the next starts.

Take a simple JavaScript program.

// 1. Synchronous code runs immediately
console.log("Start"); 

// 2. Synchronous blocking operation
for (let i = 0; i < 1e9; i++) {} // Freezes the tab for 3 seconds

// 3. Next line waits for the loop to finish
console.log("End"); 
Enter fullscreen mode Exit fullscreen mode

The main thread runs console.log("Start"). The main thread is blocked by the CPU-intensive loop. After 3 seconds, console.log("End") runs. During this time, the tab freezes (no UI updates, no event handling).

For JavaScript to behave asynchronously, there is an event loop, which:

  • Manages asynchronous operations (e.g., setTimeout, fetch) without multi-threading.
  • Delegates I/O tasks to browser APIs (which use background threads), then queues callbacks. JavaScript uses the browser's multi-threaded capabilities behind the scenes.

Take a simple asynchronous code,

console.log("Start"); 

// Asynchronous: Delegated to the browser's timer thread
setTimeout(() => console.log("Timeout"), 0); 

console.log("End");
Enter fullscreen mode Exit fullscreen mode
Start  
End  
Timeout  
Enter fullscreen mode Exit fullscreen mode

Why? setTimeout is handled by a browser timer thread (outside JavaScript's main thread). The callback () => console.log("Timeout") is queued in the callback queue after 0ms. The event loop moves it to the main thread only when the call stack is empty.

Callback Queue, Heap, and The Call Stack?

Since we have already established that every JavaScript program is a self-isolated process, and the process provides a queue, a term we will look at shortly, meanwhile, each thread has its stack. Stack another term we will look at, and it's heap. The heap is a largely unstructured area of memory allocated to all the threads currently running in a process. Let's think of the heap as an area where messengers on horseback wait before they get to deliver their messages.

Image description

When it comes to the turn of a messenger, they unmount and move to the queue. This area they are attended to in a first in first out manner, when each messenger dispatches their message, there is usually a reaction to that message, which in our case is a function call, for every message in the queue there is a function associated with it, that function is called when the message is processed out of the queue.

Image description

Each function call creates a stack frame that contains the statement and expression in the function. When that function returns a value or void, its frame is then popped out, and the next function will begin executing. If we call a function inside another function, a frame will be created for each.

The frame for the nested function sits on top of the frame for the function that called it. When the nested function is done executing, it will return and get popped off, and the main function will continue executing or return and get popped off. The items in the stack are treated in a last-in in first-out format.

The stack is a data structure that holds the frame for each function. We can deduce that this is a synchronous process, so how is concurrency achieved with the stack and the queue? In other words, the call stack is just a list of instructions in the program.

Image description

Event Loop

The event loop is simply a loop that iterates through the queue and processes any message if one is in the queue. Since we are in a JavaScript development environment, messages could also be added to the queue as a result of events happening in the DOM (Main thread).

Image description

The event loop does not care; its job is to process the messages in the queue. It is interesting to remember that a stack frame, which is in essence a function call, can emit an event that adds a new message to the queue, or it can directly add a message to the queue. So when the result of an expression might take a long time, some APIs allow us to add that result as a message when it is available to the queue, and we go on processing other things without waiting.

This is the basis of callback-based code. This is also how setTimeout and setInterval add messages asynchronously to the queue. When we write a setTimeout function, a message is added to the queue after the specified delay in milliseconds.

getSomeData(place, action)
// do something with the place
 let result = { data : place } // something
 setTimeout(() => {
   action(result)
 }, 0)
}

getSomeData("london", console.log)
console.log("hey")

Enter fullscreen mode Exit fullscreen mode

From the example above when the first function is executed a new stack frame is created, we create a variable and then use setTimeout to call the function passed in. as the second argument and give it the variable we created earlier when, if the first function has to take some time before completing the action would have to wait, but our code does not have to wait and it moves on to processing the next statement, when the result is ready action is called with the result passed in as an argument to the function.

A new stack frame is created for it, and the next message in the queue is processed if any. The above process, the way the event loop is described above, is synchronous; the event loop is usually expressed in terms of a while loop.

while(queue.waitForMessage()){
 queue.processNextMessage()
}
Enter fullscreen mode Exit fullscreen mode

To take advantage of the event loop, initially JavaScript used callback-based code, then we got promises, and finally we have async/await.

Callbaclk Based Code

Callback-based code is usually the first solution to asynchronous programming, and it involves passing a function as an argument to another function; the function we passed as an argument will delay execution until the initial function has finished running, then the function we passed as a callback will run.

let request = function(url, cb){
  let XHR = new XMLHttpRequest();
  XHR.open('GET', url, true)
  XHR.send(null)
  XHR.onload = function(){
    if(this.status === 200){
      cb(undefined, XHR.response)
    }
    else if(XHR.status !== 200){
      let err = { message: 'Error fetching resource', status: XHR.status}
      cb(err, undefined)
    }
  }
}

request('jsonplaceholder', (err, data) => {
  if(!err){
    console.log('request completed', data)
  }
  else{
    console.log('request completed', err)
  }
)

Enter fullscreen mode Exit fullscreen mode

Promises
Promises represent a cleaner way of performing asynchronous tasks. A Promise basically will return the result of an asynchronous process, and you can access that using a then method to handle the data, or a catch method to handle errors. Let's see the basic syntax of a promise.

console.log('before myProm called')
let myProm = new Promise((resolve, reject) => {
  if(1 < 2) resolve(true)
})
console.log('myProm defined')

myProm.then(data => console.log('got data back', data))
Enter fullscreen mode Exit fullscreen mode

A Promise is declared using the Promise constructor, which takes a function as an argument, and that function we pass as an argument to the promise takes in two parameters, resolve and reject.

We use resolve to return a value from the promise if everything is okay, and we call reject to return an error if something is wrong. The data that is resolved can be accessed using the then method, which takes in an argument argument represents the data that is resolved by the promise, and in the example above, we just log it to the console. We didn't handle failures in our above example, but if there was a failure, we would use the reject parameter and reject a value with it; the data that is returned by the reject method is made available on the catch method.

Async/Await
Async/Await is a new feature of JavaScript, and it makes handling asynchronous code easy. We can mark a function to be asynchronous using the async keyword, and then we use the await keyword to await some asynchronous task and continue writing other logic inside our function. Async/await is a much-improved way of dealing with promises.

When we use the async keyword to mark a function as asynchronous meaning at some point we will do some form of asynchronous work and then inside the function we use the await keyword before the asynchronous operation and in our case it is the request function, now this is what will happen; the function will start executing and when it encounters the await keyword it will suspend the function and move on to the next thing, when the value from the request is available it continues with the async function and we see our data logged out to the console. The await keyword simply waits for a promise to evaluate and return a value or an error, and then we continue with the rest of the code.
Here are the major takeaways from this topic:

  • Asynchronous programming is a technique that allows a program to execute tasks without blocking the main thread. Asynchronous programming in JavaScript is achieved via callbacks, promises, and the event loop.
  • Threads are the smallest unit of execution within a process; it is a sequence of programmed instructions that can be managed independently.
  • A process is an independent instance of a running program with its dedicated resources; it represents a program in execution.
  • The JavaScript code you write runs on a single main thread, and asynchronous behaviour can only be achieved using the Event Loop, the call stack, and the Queue.

Top comments (0)