Hello everyone!

My name is Iurii Shapkarin. With over 10 years of experience in software development and a strong foundation in Computer Science. My work underlines my dedication to innovation, problem-solving, and creating impactful user experiences.The API for this site is generated and stored as JSON and Markdown on GitHub Pages
It also uses Github Actions to help with CI, and generates a static version, website code
Website since 2013 => Proof
semver: 6.1.0

Asynchronous, Event Loop, "Threads", and Web Workers

Introduction

JavaScript, the backbone of modern web development, is fundamentally a single-threaded language. This means it can only do one thing at a time. However, web applications often need to handle multiple tasks concurrently, like fetching data, responding to user interactions, and performing complex calculations, all without freezing the user interface. This is where asynchronous programming, the event loop, and Web Workers come into play.

This article will guide you through these critical concepts, empowering you to write efficient, non-blocking, and responsive JavaScript code.

The Single-Threaded Nature of JavaScript

When we say JavaScript is single-threaded, it means it has one call stack and one memory heap. The call stack is where JavaScript keeps track of function calls. If a function takes a long time to execute (e.g., a complex calculation or a synchronous network request), it blocks the call stack. This means no other code can run, leading to an unresponsive UI – the dreaded "frozen page."

Asynchronous JavaScript: The Illusion of Concurrency

To overcome the limitations of a single thread, JavaScript employs an asynchronous, non-blocking model. This doesn't mean JavaScript suddenly becomes multi-threaded in its core execution of your main script. Instead, it offloads certain operations to the browser's Web APIs (or Node.js APIs in a server environment). These APIs can handle tasks like setTimeout, setInterval, DOM events, and network requests (fetch, XMLHttpRequest) in the background.

Once these background tasks are complete, they queue a callback function to be executed by the JavaScript engine.

Common Asynchronous Patterns

Callbacks

The traditional way to handle asynchronous operations. A function (the callback) is passed as an argument to another function and is executed once the asynchronous operation completes.

console.log("Start"); function fetchData(callback) { setTimeout(() => { console.log("Data fetched!"); callback("Some data"); }, 2000); // Simulates a 2-second network request } fetchData((data) => { console.log("Callback executed with:", data); }); console.log("End"); // Output: // Start // End // Data fetched! // Callback executed with: Some data

While functional, deeply nested callbacks ("callback hell") can make code hard to read and maintain.

Promises

Introduced in ES6, Promises provide a cleaner way to manage asynchronous operations. A Promise is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value.

console.log("Start"); function fetchDataPromise() { return new Promise((resolve, reject) => { setTimeout(() => { const success = true; // Simulate success/failure if (success) { console.log("Data fetched (Promise)!"); resolve("Some data from Promise"); } else { reject("Failed to fetch data"); } }, 2000); }); } fetchDataPromise() .then((data) => { console.log("Promise resolved with:", data); }) .catch((error) => { console.error("Promise rejected with:", error); }); console.log("End"); // Output (if success): // Start // End // Data fetched (Promise)! // Promise resolved with: Some data from Promise

Async/Await

Built on top of Promises, async/await (introduced in ES2017) offers an even more synchronous-looking syntax for writing asynchronous code, making it more readable.

console.log("Start"); function fetchDataAsync() { return new Promise((resolve) => { setTimeout(() => { console.log("Data fetched (Async/Await)!"); resolve("Some data from Async/Await"); }, 2000); }); } async function processData() { try { console.log("Calling fetchDataAsync..."); const data = await fetchDataAsync(); // Pauses execution here until promise resolves console.log("Async/Await received:", data); } catch (error) { console.error("Async/Await error:", error); } } processData(); console.log("End"); // Output: // Start // Calling fetchDataAsync... // End // Data fetched (Async/Await)! // Async/Await received: Some data from Async/Await

Notice how "End" is logged before "Data fetched..." because processData is asynchronous. await only pauses execution within the async function, not the entire JavaScript engine.

The Event Loop: Orchestrating Asynchronous Operations

The Event Loop is the heart of JavaScript's concurrency model. It's a mechanism that allows JavaScript to perform non-blocking operations, despite being single-threaded, by offloading operations to the browser's APIs and processing results in a specific order.

Components of the Event Loop

Understanding the event loop requires knowing its key components:

  1. Call Stack: This is where JavaScript keeps track of function calls. When a script calls a function, it's added (pushed) to the top of the call stack. When the function finishes executing, it's removed (popped) from the stack. JavaScript executes only one function at a time – the one at the top of the stack.
  2. Web APIs (Browser APIs / Node.js APIs): Asynchronous operations (like setTimeout, DOM events, fetch requests) are not handled by the JavaScript engine directly. Instead, they are passed to the relevant Web API provided by the browser (or equivalent APIs in Node.js). These APIs run on separate threads from the main JavaScript thread.
  3. Task Queue (Callback Queue / Macrotask Queue): When a Web API finishes its work (e.g., a timer expires, data is fetched), the callback function associated with that operation is not immediately executed. Instead, it's placed in the Task Queue (also known as the Callback Queue or Macrotask Queue). These tasks are processed one at a time, but only when the Call Stack is empty.
  4. Microtask Queue: This queue has a higher priority than the Task Queue. Callbacks for Promises (.then(), .catch(), .finally()) and MutationObserver are added to the Microtask Queue. Microtasks are executed after the currently executing script finishes and an opportunity is given for UI rendering, but before any task from the Task Queue is processed. All microtasks in the queue are executed before the event loop picks the next task from the Task Queue.
  5. Event Loop: This is a continuous process that monitors the Call Stack and the Task/Microtask Queues. Its job is to:
    • Execute synchronous code found in the Call Stack.
    • Once the Call Stack is empty, check the Microtask Queue. If there are microtasks, execute them all, one by one, until the Microtask Queue is empty. (New microtasks queued during this process are also executed before moving on).
    • After the Microtask Queue is empty, the Event Loop checks the Task Queue. If there's a task, the oldest one is moved to the Call Stack and executed.
    • Repeat this cycle.

How It Works

Here's a simplified view of the process:

  1. Synchronous code is executed line by line and pushed onto the Call Stack.
  2. When an asynchronous operation (like setTimeout, fetch) is encountered, it's handed off to the browser's Web API. The JavaScript engine doesn't wait for it; it continues executing the rest of the synchronous code.
  3. Once the Web API finishes its task (e.g., timer expires, data arrives), it pushes the associated callback function into the Task Queue (or Microtask Queue for promises).
  4. The Event Loop continuously checks if the Call Stack is empty.
  5. If the Call Stack is empty, the Event Loop first checks the Microtask Queue. All available microtasks are moved one by one to the Call Stack and executed until the Microtask Queue is empty.
  6. Only after the Microtask Queue is empty, the Event Loop checks the Task Queue. If there's a task, the oldest one is moved to the Call Stack and executed.

This mechanism ensures that the main thread is not blocked by long-running asynchronous operations, allowing the UI to remain responsive.

Classic Example of Execution Order

The event loop follows a strict order:

  1. Execute all synchronous tasks on the call stack.
  2. Process all microtasks in the microtask queue.
  3. Process the first task in the macrotask queue.
  4. Repeat.
console.log('1. Script start'); setTimeout(function() { console.log('5. setTimeout callback (Task Queue)'); }, 0); Promise.resolve().then(function() { console.log('3. Promise.resolve().then (Microtask Queue)'); }).then(function() { console.log('4. Chained Promise.then (Microtask Queue)'); }); console.log('2. Script end'); // Output: // 1. Script start // 2. Script end // 3. Promise.resolve().then (Microtask Queue) // 4. Chained Promise.then (Microtask Queue) // 5. setTimeout callback (Task Queue)

This example clearly demonstrates the order of execution: synchronous code first, then all microtasks, then tasks from the callback queue.

Event Loop Analogy: The Restaurant

Imagine you're at a restaurant:

  1. The Chef (Call Stack): Prepares one order at a time. If a dish takes long to cook, the chef moves it to a separate station (like an oven with a timer - a Web API) and starts on the next order.
  2. The Waiter (Putting tasks in Queues): The waiter keeps an eye on all pending tasks. When a dish in the oven is ready (Web API finishes), the waiter doesn't interrupt the chef. Instead, they place a note for the chef (the callback function) in an "orders ready" tray (Task Queue or Microtask Queue).
  3. The Manager (Event Loop): The manager ensures the chef only works on one task from the Call Stack at a time. When the chef is free (Call Stack is empty), the manager first checks for any urgent notes (Microtask Queue). If there are any, the chef handles all of them. Then, the manager gives the chef the next "order ready" note from the regular tray (Task Queue). This keeps the workflow smooth and ensures urgent tasks are prioritized.

Event Loop Analogy: Baking Cookies

Another way to visualize this is by imagining you're baking cookies, and you can only do one thing at a time:

  1. Your To-Do List (Call Stack): You follow your recipe step-by-step (e.g., mix dough, pour milk). Each step is a task on your to-do list.
  2. Simple, Quick Tasks (Synchronous Code): Mixing dough or pouring milk are quick and done immediately.
  3. Waiting Tasks (Asynchronous Code & Web APIs): When you put cookies in the oven, you set a timer (like setTimeout). You don't just stand there. The oven (a Web API) handles the baking in the background.
  4. The "Timer Dings!" (Callback in Queue): When the timer dings, it's like the Web API has finished. The task "take cookies out" (the callback function) is now ready. It gets added to a list of "things to do when I'm free" (Task Queue).
  5. Your Kitchen Helper (Event Loop): This helper constantly checks if you're done with your current immediate task (Call Stack empty).
    • First, they check for any "super urgent small notes" you left yourself, like "quickly wipe counter" (Microtask Queue). You do all of these first.
    • Then, if you're free, the helper hands you the next "thing to do when I'm free" from your list, like "take cookies out" (Task Queue). This way, you're always busy doing something productive and not just waiting for the cookies to bake.

So, No Real Threads in Main JavaScript?

For the main script and UI interactions, JavaScript operates on a single thread. The "concurrency" achieved through async/await, Promises, and callbacks is cooperative multitasking managed by the Event Loop, not true parallelism. Operations handled by Web APIs might run on separate threads provided by the browser, but your JavaScript code interacting with their results still runs on the main thread via the event loop.

This is where Web Workers come in.

Web Workers: True Parallelism in the Browser

Web Workers provide a way to run JavaScript in background threads, separate from the main execution thread that handles the UI. This allows you to perform computationally intensive tasks without freezing the user interface.

Key Characteristics of Web Workers

  • Separate Threads: Workers run in their own global context, distinct from the window object of the main page.
  • No DOM Access: Workers cannot directly manipulate the DOM. This is a crucial safety measure to prevent race conditions and complex UI state management issues.
  • Communication via Messaging: The main thread and workers communicate by sending messages using the postMessage() method and receiving them via the onmessage event handler. Data is copied (not shared) between threads, with Transferable Objects being an exception for performance.
  • Independent Execution: A worker can perform tasks like complex calculations, data processing, or I/O operations without affecting the responsiveness of the main page.
  • Limitations: Workers have access to a subset of JavaScript features. For example, they can use XMLHttpRequest for network requests, setTimeout/setInterval, and other non-UI related APIs. They don't have access to alert, confirm, or direct DOM manipulation.

Creating and Using a Web Worker

1. main.js (Main Thread Script):

// main.js if (window.Worker) { console.log("Main: Creating worker..."); const myWorker = new Worker("worker.js"); // Path to the worker script // Sending a message to the worker myWorker.postMessage({ command: "startCalculation", data: 1000000000 }); console.log("Main: Message posted to worker"); // Receiving messages from the worker myWorker.onmessage = function(event) { console.log("Main: Message received from worker:", event.data); if (event.data.result) { alert(`Calculation result: ${event.data.result}`); } }; // Handling errors from the worker myWorker.onerror = function(error) { console.error("Main: Error from worker:", error.message, error); }; // Terminating a worker (if needed) // myWorker.terminate(); } else { console.log("Your browser doesn't support Web Workers."); } console.log("Main: Script end");

2. worker.js (Worker Script):

// worker.js console.log("Worker: Script started"); self.onmessage = function(event) { console.log("Worker: Message received from main script:", event.data); const { command, data } = event.data; if (command === "startCalculation") { let result = 0; for (let i = 0; i < data; i++) { result += Math.sqrt(i) * Math.sin(i); // Some dummy heavy calculation } console.log("Worker: Calculation finished"); // Sending the result back to the main thread self.postMessage({ result: result, status: "completed" }); } }; // Workers can also have their own error handling self.onerror = function(error) { console.error("Worker: Error caught in worker:", error); // Optionally, inform the main thread // self.postMessage({ error: error.message }); }; console.log("Worker: Event listener set up");

To run this example:

  1. Save the above code into main.js and worker.js in the same directory.
  2. Create an index.html file that includes main.js:
    <!DOCTYPE html>
    <html>
    <head>
        <title>Web Worker Demo</title>
    </head>
    <body>
        <h1>Web Worker Demo</h1>
        <p>Check the console for messages. An alert will show the result of the calculation from the worker.</p>
        <script src="main.js"></script>
    </body>
    </html>
    
  3. Open index.html in your browser and check the console.

You'll observe that "Main: Script end" logs before the worker finishes its calculation and sends back the result, demonstrating non-blocking behavior. The UI (if there were more interactive elements) would remain responsive during the worker's heavy computation.

When to Use Web Workers

  • CPU-Intensive Tasks: Image processing, video/audio manipulation, complex mathematical calculations, data encryption/decryption.
  • Large Data Processing: Sorting, filtering, or analyzing large datasets without freezing the UI.
  • Background Syncing: Keeping local data synchronized with a server.
  • Prefetching Data: Loading data in the background that the user might need soon.

Limitations and Considerations

  • Overhead: Creating workers has some overhead. They are not ideal for very small, quick tasks.
  • Data Transfer: Data passed between the main thread and workers via postMessage() is copied, which can be slow for very large data structures. Transferable Objects (like ArrayBuffer) can mitigate this by transferring ownership, but require careful handling.
  • Complexity: Managing communication and state between multiple threads can add complexity to your application.
  • Debugging: Debugging workers can sometimes be trickier than debugging single-threaded code, though browser developer tools have improved significantly in this area.

Additional Examples of Event Loop Behavior

console.log('Start'); setTimeout(() => { console.log('Timeout 1'); Promise.resolve().then(() => { console.log('Promise 1'); }).then(() => { console.log('Promise 2'); }); }, 0); Promise.resolve().then(() => { console.log('Promise 3'); setTimeout(() => { console.log('Timeout 2'); }, 0); return Promise.resolve(); }).then(() => { console.log('Promise 4'); }); console.log('End'); // Output: // Start // End // Promise 3 // Promise 4 // Timeout 1 // Promise 1 // Promise 2 // Timeout 2

Agent Clusters and Memory Sharing

JavaScript's execution model includes the concept of "agent clusters" - groups of agents (like main thread, workers, etc.) that can share memory with each other. This is particularly relevant when working with SharedArrayBuffer and Atomics API for concurrent memory access.

The browser ensures that agent clusters maintain consistency and prevent issues like deadlocks by enforcing rules about agent activation and termination.

Job Queues in Detail

While we've discussed the Task (Macrotask) and Microtask queues, it's worth noting that the JavaScript execution model, as defined by specifications like ECMAScript and HTML, describes "Job Queues" more broadly.

  • Script Jobs: These are for the initial execution of a script.
  • Promise Reaction Jobs (Microtasks): These handle the reactions to fulfilled or rejected Promises (e.g., callbacks passed to .then(), .catch(), .finally()). These are processed in the Microtask Queue.
  • Other Task Queues (Macrotasks): Browsers manage various task queues for different types of tasks, such as timers (setTimeout, setInterval), UI events (clicks, mouse movements), I/O operations, etc. The event loop selects one task from one of these queues (implementation-dependent, but often FIFO within a specific queue).

The crucial distinction remains the priority: Microtasks (Promise Reaction Jobs) are always processed to completion after the current synchronous script block finishes and before the next Macrotask is picked from any of the other task queues.

Conclusion

Understanding asynchronous JavaScript, the Event Loop, and Web Workers is crucial for building high-performance, responsive web applications.

  • Asynchronous patterns (Callbacks, Promises, Async/Await) with the Event Loop allow JavaScript to handle multiple operations without blocking the main thread, creating an illusion of concurrency.
  • Web Workers provide true parallelism by enabling background JavaScript execution on separate threads, perfect for offloading heavy computations and keeping the UI smooth.

By mastering these concepts, you can unlock the full potential of JavaScript and deliver a superior user experience. Remember to choose the right tool for the job: use asynchronous patterns for I/O-bound tasks and general non-blocking behavior, and leverage Web Workers when you have CPU-bound tasks that could otherwise degrade UI performance.

Event Loop Analogy

Imagine you're at a restaurant:

  1. The Chef (Call Stack): Prepares one order at a time. If a dish takes long to cook, the chef moves it to a separate station and starts on the next order.
  2. The Waiter (Event Queue): Keeps an eye on pending tasks and brings them back to the chef once they're ready.
  3. The Manager (Event Loop): Ensures the chef only works on one task at a time and keeps the workflow smooth.

Additional Examples

console.log('Start'); setTimeout(() => { console.log('Timeout 1'); Promise.resolve().then(() => { console.log('Promise 1'); }).then(() => { console.log('Promise 2'); }); }, 0); Promise.resolve().then(() => { console.log('Promise 3'); setTimeout(() => { console.log('Timeout 2'); }, 0); return Promise.resolve(); }).then(() => { console.log('Promise 4'); }); console.log('End'); // Output: // Start // End // Promise 3 // Promise 4 // Timeout 1 // Promise 1 // Promise 2 // Timeout 2

Agent Clusters and Memory Sharing

JavaScript's execution model includes the concept of "agent clusters" - groups of agents (like main thread, workers, etc.) that can share memory with each other. This is particularly relevant when working with SharedArrayBuffer and Atomics API for concurrent memory access.

The browser ensures that agent clusters maintain consistency and prevent issues like deadlocks by enforcing rules about agent activation and termination.