javascriptnotesConcurrency and Asynchronous JavaScript

Synchronous and asynchronous functions

Synchronous functions are blocking while asynchronous functions are not. In synchronous functions, statements complete before the next statement is run. As a result, programs containing only synchronous code are evaluated exactly in order of the statements. The execution of the program is paused if one of the statements take a very long time.

Asynchronous functions usually accept a callback as a parameter and execution continue on to the next line immediately after the asynchronous function is invoked. The callback is only invoked when the asynchronous operation is complete and the call stack is empty. Heavy duty operations such as loading data from a web server or querying a database should be done asynchronously so that the main thread can continue executing other operations instead of blocking until that long operation to complete (in the case of browsers, the UI will freeze).

Callbacks

A callback function is a function that is passed as an argument to another function and is executed after some operation has been completed. This is particularly useful in asynchronous programming, where operations like network requests, file I/O, or timers need to be handled without blocking the main execution thread.

  • Synchronous callbacks are executed immediately within the function they are passed to. They are blocking and the code execution waits for them to complete.
Example
function greet(name, callback) {
  console.log("Hello " + name);
  callback();
}
 
function sayGoodbye() {
  console.log("Goodbye!");
}
 
greet("Alice", sayGoodbye);
// Output:
// Hello Alice
// Goodbye!
  • Asynchronous callbacks are executed after a certain event or operation has been completed. They are non-blocking and allow the code execution to continue while waiting for the operation to finish.
Example
function fetchData(callback) {
  setTimeout(() => {
    const data = { name: "John", age: 30 };
    callback(data);
  }, 1000);
}
 
fetchData((data) => {
  console.log(data);
});
// Output after 1 second:
// { name: 'John', age: 30 }

Common use cases:

  • Network requests: Fetching data from an API
  • File I/O: Reading or writing files
  • Timers: Delaying execution using setTimeout or setInterval
  • Event handling: Responding to user actions like clicks or key presses

When dealing with asynchronous operations, it’s important to handle errors properly. A common pattern is to use the first argument of the callback function to pass an error object, if any.

Example
function fetchData(callback) {
  setTimeout(() => {
    const error = null;
    const data = { name: "John", age: 30 };
    callback(error, data);
  }, 1000);
}
 
fetchData((error, data) => {
  if (error) {
    console.error("An error occurred:", error);
  } else {
    console.log(data);
  }
});

Promises

Promises in JavaScript are objects that represent the eventual completion (or failure) of an asynchronous operation and its resulting value. They have three states: pending, fulfilled, and rejected. You can handle the results of a promise using the .then() method for success and the .catch() method for errors. The finally block is used to execute code after a try and catch block, regardless of whether an error was thrown or caught. It ensures that certain cleanup or finalization code runs no matter what. They provide a cleaner, more readable way to handle asynchronous code compared to traditional callback functions.

Creating a promise

You create a promise using the Promise constructor, which takes a function with two arguments: resolve and reject. These are callbacks that you call to change the state of the promise.

Immediate Execution: The function you pass to the Promise constructor is executed immediately when the Promise is created. It’s generally used for wrapping an asynchronous operation directly.

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true; // or false
    if (success) {
      resolve("Operation successful");
    } else {
      reject("Operation failed");
    }
  }, 1000);
});
 
myPromise
  .then((result) => console.log(result))
  .catch((error) => console.error(error));

Or create a function that returns a promise.

Deferred Execution: The function fetchData does not execute the Promise until it is called. This allows you to create Promises based on different conditions or contexts.

const fetchData = () => {
  return new Promise((resolve, reject) => {
    fetch("https://api.example.com/data")
      .then((response) => response.json())
      .then((data) => resolve(data))
      .catch((error) => reject(error));
  });
};
 
fetchData()
  .then((data) => console.log(data))
  .catch((error) => console.error(error));

Chaining promises

Promises can be chained to handle multiple asynchronous operations in sequence. Each .then() returns a new promise, allowing for further chaining.

promise
  .then((result) => {
    console.log(result);
    return anotherPromise;
  })
  .then((anotherResult) => {
    console.log(anotherResult);
  })
  .catch((error) => {
    console.error(error);
  });

Combining promises

You can use Promise.all() to run multiple promises in parallel and wait for all of them to complete.

let promise1 = Promise.resolve("First");
let promise2 = Promise.resolve("Second");
 
Promise.all([promise1, promise2]).then((results) => {
  console.log(results); // ['First', 'Second']
});

Callbacks vs promises

Avoid callback hell which can be unreadable

Callback hell, also known as the “pyramid of doom,” is a phenomenon that occurs when you have multiple nested callbacks in your code. This can lead to code that is difficult to read, maintain, and debug. Here’s an example of callback hell:

Example
function getFirstData(callback) {
  setTimeout(() => {
    callback({ id: 1, title: "First Data" });
  }, 2000);
}
 
function getSecondData(data, callback) {
  setTimeout(() => {
    callback({ id: data.id, title: data.title + " Second Data" });
  }, 2000);
}
 
function getThirdData(data, callback) {
  setTimeout(() => {
    callback({ id: data.id, title: data.title + " Third Data" });
  }, 2000);
}
 
// Callback hell
getFirstData((data) => {
  getSecondData(data, (data) => {
    getThirdData(data, (result) => {
      console.log(result); // Output: {id: 1, title: "First Data Second Data Third Data"}
    });
  });
});

Promises address the problem of callback hell by providing a more linear and readable structure for your code.

Example
// Example of sequential asynchronous code using setTimeout and Promises
function getFirstData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 1, title: "First Data" });
    }, 2000);
  });
}
 
function getSecondData(data) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: data.id, title: data.title + " Second Data" });
    }, 2000);
  });
}
 
function getThirdData(data) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: data.id, title: data.title + " Third Data" });
    }, 2000);
  });
}
 
getFirstData()
  .then(getSecondData)
  .then(getThirdData)
  .then((data) => {
    console.log(data); // Output: {id: 1, title: "First Data Second Data Third Data"}
  })
  .catch((error) => console.error("Error:", error));

Makes it easy to write sequential asynchronous code that is readable with .then(): In the above code example, we use .then() method to chain these Promises together, allowing the code to execute sequentially. It provides a cleaner and more manageable way to handle asynchronous operations in JavaScript.

Makes it easy to write parallel asynchronous code with Promise.all(): Both Promise.all() and callbacks can be used to write parallel asynchronous code. However, Promise.all() provides a more concise and readable way to handle multiple Promises, especially when dealing with complex asynchronous workflows.

Example
function getData1() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 1, title: "Data 1" });
    }, 2000);
  });
}
 
function getData2() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 2, title: "Data 2" });
    }, 2000);
  });
}
 
function getData3() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 3, title: "Data 3" });
    }, 2000);
  });
}
 
Promise.all([getData1(), getData2(), getData3()])
  .then((results) => {
    console.log(results); // Output: [[{ id: 1, title: 'Data 1' }, { id: 2, title: 'Data 2' }, { id: 3, title: 'Data 3' }]
  })
  .catch((error) => {
    console.error("Error:", error);
  });

With promises, these scenarios which are present in callbacks-only coding, will not happen:

  • Call the callback too early
  • Call the callback too late (or never)
  • Call the callback too few or too many times
  • Fail to pass along any necessary environment/parameters
  • Swallow any errors/exceptions that may happen

Async/Await

Syntax

async/await is a feature introduced in ECMAScript 2017 (ES8) that allows you to write asynchronous code in a more synchronous-looking manner. It is built on top of promises and provides a cleaner and more readable way to handle asynchronous operations. By using the async keyword before a function, you can use the await keyword inside that function to pause execution until a promise is resolved. This makes asynchronous code look and behave more like synchronous code, making it easier to read and maintain.

Example
async function fetchData() {
  try {
    const response = await fetch("https://api.example.com/data");
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error("Error fetching data:", error);
  }
}

The async keyword is used to declare an asynchronous function. When a function is declared as async, it automatically returns a promise. This means you can use the await keyword inside it to pause the execution of the function until a promise is resolved.

Example
async function exampleFunction() {
  return "Hello, World!";
}
 
exampleFunction().then(console.log); // Output: Hello, World!

The await keyword can only be used inside an async function. It pauses the execution of the function until the promise is resolved, and then returns the resolved value. If the promise is rejected, it throws an error, which can be caught using a try...catch block.

Example
async function fetchData() {
  try {
    const response = await fetch("https://api.example.com/data");
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error("Error fetching data:", error);
  }
}

Simplifying asynchronous code

Before async/await, handling asynchronous operations often involved chaining multiple .then() calls, which could lead to “callback hell” or “pyramid of doom.” async/await flattens this structure, making the code more readable and easier to maintain.

Example with promises
fetch("https://api.example.com/data")
  .then((response) => response.json())
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
    console.error("Error fetching data:", error);
  });
Example with async/await
async function fetchData() {
  try {
    const response = await fetch("https://api.example.com/data");
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error("Error fetching data:", error);
  }
}

Error handling

Error handling with async/await is more straightforward compared to promises. You can use try...catch blocks to handle errors, making the code cleaner and more intuitive.

Example
async function fetchData() {
  try {
    const response = await fetch("https://api.example.com/data");
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error("Error fetching data:", error);
  }
}

Workers

Workers in JavaScript are a way to run scripts in background threads, separate from the main execution thread of a web page. This allows for long-running or computationally intensive tasks to be offloaded from the main thread, preventing the user interface from becoming unresponsive or janky.

Web workers / Dedicated workers

  • Run scripts in background threads separate from the main UI thread.
  • Designed for CPU-intensive tasks like data processing, mathematical computations, etc. Generally the non-async work.
  • Cannot directly access the DOM or other main thread resources for security.
  • Communicates with main thread via asynchronous message passing – postMessage() and onmessage/ 'message'.
  • Terminated when main script is unloaded or explicitly terminated.

Web workers can be used for:

  • Image/video processing
  • Data compression
  • Complex math
Creating a web worker

To create a web worker, you need a separate JavaScript file that contains the code for the worker. Here’s an example:

main.js (main script)

// Check if the browser supports workers
if (window.Worker) {
  // Create a new Worker
  const myWorker = new Worker("worker.js");
 
  // Post a message to the worker
  myWorker.postMessage("Hello, Worker!");
 
  // Listen for messages from the worker
  myWorker.onmessage = function (event) {
    console.log("Message from Worker:", event.data);
  };
 
  // Error handling
  myWorker.onerror = function (error) {
    console.error("Error from Worker:", error);
  };
}

worker.js (worker script)

// Listen for messages from the main script
onmessage = function (event) {
  console.log("Message from Main Script:", event.data);
 
  // Perform a task (e.g., some computation)
  const result = event.data + " - Processed by Worker";
 
  // Post the result back to the main script
  postMessage(result);
};

In this example:

  • main.js creates a worker using the Worker constructor and specifies worker.js as the script to run in the worker thread.
  • It posts a message to the worker using postMessage().
  • The worker script (worker.js) listens for messages from the main script using onmessage.
  • After processing the message, the worker posts a message back to the main script using postMessage().
  • The main script listens for messages from the worker using onmessage on the Worker instance.

Service workers

  • Act as a network proxy between web app, browser, and network.
  • Can intercept and handle network requests, cache resources.
  • Enable offline functionality and push notifications.
  • Have a lifecycle managed by the browser (install, activate, update).
  • No access to DOM and main thread resources for security.

Service workers can be used for:

  • Caching
  • Offline support
  • Request handling
  • Background sync
Creating a service worker

main.js (main script)

if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("/service-worker.js")
    .then(function (registration) {
      console.log("Service Worker registered:", registration);
    })
    .catch(function (err) {
      console.log("Service Worker registration failed:", err);
    });
}

service-worker.js (service worker script)

self.addEventListener("fetch", function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      // return cached response if available
      if (response) {
        return response;
      }
 
      // Otherwise, fetch from network
      return fetch(event.request);
    })
  );
});

In this example:

  • The main script registers a service worker at /service-worker.js.
  • The service worker listens for the fetch() event, which is fired whenever the browser makes a network request.
  • The service worker first checks if the requested resource is cached using caches.match(event.request).
  • If it is, it returns the cached response. Otherwise, it fetches the resource from the network using fetch(event.request).

Shared workers

  • Can be accessed from multiple scripts in different windows/tabs/iframes.
  • Allow data sharing between browser contexts via a messaging interface.
  • Similar to dedicated web workers but with a broader scope.
  • Can be used for sharing across multiple windows.

Bonus: Worklets

The Worklet interface is a lightweight version of Web Workers and gives developers access to low-level parts of the rendering pipeline. With Worklets, you can run JavaScript and WebAssembly code to do graphics rendering or audio processing where high performance is required.

You are not expected to know about worklets, so it won’t be covered in great detail. Read more about worklets on MDN.

Considerations and limitations

  • Same-Origin policy: Workers must comply with the same-origin policy, meaning the script that creates the worker and the worker script itself must be from the same origin.
  • No DOM access: Workers do not have direct access to the DOM. They can communicate with the main thread through messages.
  • Performance: Creating and managing workers incurs overhead. They should be used judiciously for tasks that truly benefit from parallel execution.
  • Error handling: Proper error handling mechanisms should be in place to handle any issues within the worker scripts.