Asynchronous Programming 101: From Operating Systems Class to JavaScript’s Async/Await

Slug
understanding-async-await
Description
From understanding concurrency and parallelism in the classroom, to applying them to in modern web development.
Date
May 8, 2024
Published
Published

Introduction: From Operating Systems Lectures to Practice

 
Back in school when I was studying operating systems, concepts like concurrency and parallelism were presented as essential tools for Operating Systems to be able to effectively and efficiently schedule tasks. These concept were at times abstract, discussed in the context of processes, threads, and CPU scheduling. Around the same time, I was building an e-commerce web application using Next.js (essentially React + an API Ecosystem), which required using JavaScript's async/await pattern to manage asynchronous tasks (eg. fetching data from multiple different endpoints within a single web page in a way that was fast and responsive to the user).
 
As I continued to learn about both of these topics in parallel, the connections between them quickly began to form in my head - which, after pursued a bit, brought deeper levels of understanding in both.
 
In this post, I want to guide you through the journey I took—from understanding concurrency and parallelism in theory to applying them to in modern web development using JavaScript's async/await mechanic. We’ll start from the basics, hopefully :) explain each concept clearly, and provide code examples to illustrate what these things look like in practice.
 

Part 1: Concurrency and Parallelism in Operating Systems

Before diving into JavaScript, let's start with the basics of concurrency and parallelism, to ensure we're all on a similar footing going into this.

1.1 What is Concurrency?

  • Definition: Concurrency is about managing multiple tasks at the same time but not necessarily executing them simultaneously. In operating systems, this is often achieved through techniques like time slicing (where a single CPU core switches between 2 or more tasks) or context switching (where the state of a process is saved so it can be resumed later).
  • Example: Imagine you're in a coffee shop. The barista takes an order (Task 1), starts brewing the coffee (Task 2), and while the coffee is brewing, takes another order (Task 3). The barista is managing multiple tasks concurrently, even though they’re only actively working on one task at a time. This represents a process being executed concurrently, on a single CPU-core (represented by a single Barista performing the tasks here).
  • Code Analogy (Pseudocode):
function takeOrder() { console.log("Taking order..."); } function brewCoffee() { console.log("Brewing coffee..."); } function serveCoffee() { console.log("Serving coffee..."); } // Simulating concurrency takeOrder(); brewCoffee(); // This task takes time takeOrder(); // The barista takes another order while coffee is brewing serveCoffee();
 

1.2 What is Parallelism?

  • Definition: Parallelism involves executing multiple tasks simultaneously, usually on different processors or cores. This is possible when the system has multiple CPUs or a multi-core CPU.
  • Example: Imagine two baristas working side by side. Each barista represents a CPU core on a multi-core CPU. One is taking orders while the other is brewing coffee at the same time. Both tasks are happening simultaneously, which speeds up service.
  • Code Analogy (Pseudocode):
function barista1() { console.log("Barista 1: Taking order..."); console.log("Barista 1: Serving coffee..."); } function barista2() { console.log("Barista 2: Brewing coffee..."); } // Simulating parallelism barista1(); barista2(); // Both baristas work at the same time, representing processes running on different CPU cores
 

Part 2: Transitioning from OS Theory to JavaScript

Now that we have a feel for the basics of concurrency and parallelism in the context of operating system process scheduling, let's see how these concepts translate into JavaScript, specifically in modern web development.

2.1 The Single-threaded Nature of JavaScript

  • Explanation: JavaScript is single-threaded, meaning it can only execute one piece of code at a time. This might sound limiting, that was definitely my first thought, but JavaScript uses a clever mechanism called the event loop to manage multiple tasks efficiently without blocking the main thread. If you are new to JS, I highly recommend you get intimately familiar with it. It is extremely important across all JS-based frameworks/runtimes (React, Angular, Next.js, Node.js, you name it...).
  • Event Loop: The event loop allows JavaScript to handle tasks like fetching data from a server, waiting for user input, or processing timers. It does this by queuing tasks and executing them one at a time when the main thread is available.
  • Code Example:
 
console.log("Start"); setTimeout(() => { console.log("This runs after 2 seconds"); }, 2000); console.log("End");
Explanation: In this example, even though the setTimeout function delays the execution of its callback, the event loop ensures that "End" is logged before the delayed message because the main thread isn't blocked.
 
Video preview
Quick ~7 min video. Highly recommend.

2.2 The Evolution of Asynchronous Programming in JavaScript

  • Callbacks: Originally, JavaScript handled asynchronous operations with callbacks. A callback is a function passed to another function, which is then executed after the asynchronous operation completes.
  • Callback Hell: As applications grew more complex, deeply nested callbacks led to messy and hard-to-read code, a problem known as "callback hell."
  • Code Example of Callback Hell:
 
getUserData(userId, (user) => { getPosts(user.id, (posts) => { getComments(posts[0].id, (comments) => { console.log(comments); }); }); });
Explanation: This nested structure makes the code difficult to follow and maintain.
 
  • Promises: To improve this, JavaScript introduced promises, which represent a value that will be available in the future. Promises allow for chaining operations and better error handling.
  • Async/Await: async/await was introduced to further simplify asynchronous code. It allows developers to write asynchronous operations in a way that looks synchronous, making the code easier to read and understand.
 

Part 3: Deep Dive into Async/Await with Practical Examples

Now that we understand the history and evolution of asynchronous programming in JavaScript, let's dive into async/await and see how it implements the concepts of concurrency and parallelism.
 

3.1 Understanding Async/Await through Concurrency

  • Async Functions: An async function is a function that always returns a promise. You use the await keyword inside this function to pause its execution until the promise is resolved.
  • Concurrency with Async/Await: Just like the time slicing in an OS, async/await allows JavaScript to handle multiple tasks by pausing one operation (await) and continuing with others until the first is ready to proceed.
  • Code Example:
 
async function fetchData() { const userData = await fetchUserData(); const details = await fetchUserDetails(userData.id); console.log(details); } fetchData();
Explanation: The fetchData function pauses at each await until the asynchronous operation completes. This allows JavaScript to manage multiple tasks efficiently, even though it’s single-threaded.
 

3.2 Applying Parallelism with JavaScript

  • Promise.all for Parallelism: While JavaScript is single-threaded, you can achieve a form of parallelism by starting multiple promises at the same time with Promise.all. This method allows you to execute multiple promises simultaneously and wait for all of them to complete.
  • Code Example:
 
async function fetchMultipleData() { const [userData, posts] = await Promise.all([fetchUserData(), fetchPosts()]); console.log(userData, posts); } fetchMultipleData();
Explanation: In this example, fetchUserData and fetchPosts start simultaneously. The function waits for both operations to complete before moving on, similar to how an OS would handle multiple threads in parallel.
 

3.3 Real-World Applications Across Frameworks

  • React and useEffect: In React, managing side effects like data fetching often involves using useEffect with async/await to handle asynchronous tasks within a component. This allows your component to perform actions like fetching data from an API without blocking the UI.
  • Code Example:
 
useEffect(() => { const fetchData = async () => { const data = await fetchSomeData(); setData(data); }; fetchData(); }, []);
 
  • Next.js Data Fetching: Next.js enhances data fetching with methods like getServerSideProps, which often use async/await to handle server-side data before rendering a page. This is crucial for server-side rendering and ensuring that data is ready before the page is delivered to the user.
  • Code Example:
 
export async function getServerSideProps() { const data = await fetchDataFromAPI(); return { props: { data } }; }
Explanation: getServerSideProps is executed on the server side. It fetches data before the page is rendered, ensuring that the data is available when the page reaches the client.
 

 

Part 4: Putting It All Together - Async/Await as a Concurrency Tool

 

4.1 Async/Await in the Event Loop

  • Recap: Just as the OS uses processes and threads to manage tasks concurrently, JavaScript uses the event loop with async/await to manage asynchronous operations. This ensures that long-running tasks don’t block the main thread, keeping the UI responsive.
  • Code Example:
 
async function handleRequest() { const userData = await fetchUserData(); console.log("User data fetched"); const posts = await fetchPosts(userData.id); console.log("Posts fetched"); return { userData, posts }; } handleRequest().then(data => console.log(data));
Explanation: Here, the event loop schedules these asynchronous operations. The function handleRequest fetches user data, waits for it to complete, then fetches posts based on the user ID. All of this happens without blocking other operations.
 

4.2 When Not to Use Async/Await

  • Performance Considerations: While async/await simplifies code, it’s not always the best choice for highly parallel tasks. In cases where you need true parallelism (e.g., CPU-intensive tasks), using tools like Web Workers might be more efficient.
  • Code Example with Web Workers:
 
// worker.js self.onmessage = function(event) { const result = heavyComputation(event.data); self.postMessage(result); } // main.js const worker = new Worker('worker.js'); worker.postMessage(data); worker.onmessage = function(event) { console.log("Result from worker:", event.data); }
Explanation: Web Workers run in the background on separate threads, allowing you to perform tasks like image processing or data crunching without blocking the main thread.
 

Conclusion: From Concepts to Code

 
I hope this little writeup provided you with at least some value! My goal was to approach Asynchronous Web Programming from a non-traditional direction by extending academic understandings in concurrency/parallelism to modern asynchronous web development.
 
Whether you’re just starting out or you’re an experienced developer, I highly suggest taking the time to get comfortable with these concepts as they will make you much more effective and aware when using them across your software development ventures, front or back-end:
  • JavaScript’s Event Loop
  • Concurrency / Parallellism
  • Asynchronous vs Synchronous Web Programming
 
For newer developers: If you're interested in any sort of web development that includes JavaScript/TypeScript, I higgghhllyyy recommend getting familiar with JavaScript's event loop. In my opinion a deep understanding of it will be wildly powerful throughout your early (and beyond) careers. Below I have 2 videos which I cannot recommend enough which were massively helpful for me:
 
Video preview
To support Async/Await + some Event Loop understanding.
 
Video preview
Legendary video. Deeper dive into JS’ event loop.

Contact Me!