Chapter 9. Asynchronous Programming Patterns

Asynchronous JavaScript programming allows you to execute long-running tasks in the background while allowing the browser to respond to events and run other code to handle these events. Asynchronous programming is relatively new in JavaScript, and the syntax to support it was not available when the first edition of this book was published.

JavaScript concepts such as promise, async, and await make your code tidier and easy to read without blocking the main thread. async functions were introduced as part of ES7 in 2016 and are now supported on all browsers. Let’s look at some patterns that use these features to structure our application flows.

Asynchronous Programming

In JavaScript, synchronous code is executed in a blocking manner, meaning that the code is executed serially, one statement at a time. The following code can run only after the execution of the current statement has been completed. When you call a synchronous function, the code inside that function will execute from start to finish before the control returns to the caller.

On the other hand, asynchronous code is executed in a nonblocking manner, meaning that the JavaScript engine can switch to execute this code in the background while the currently running code is waiting on something. When you call an asynchronous function, the code inside the function will execute in the background, and the control returns to the caller immediately.

Here is an example of synchronous code in JavaScript:

function synchronousFunction() {
  // do something
}

synchronousFunction();
// the code inside the function is executed before this line

And here is an example of asynchronous code in JavaScript:

function asynchronousFunction() {
  // do something
}

asynchronousFunction();
// the code inside the function is executed in the background
// while control returns to this line

You can generally use asynchronous code to perform long-running operations without blocking the rest of your code. Asynchronous code is suitable when making network requests, reading or writing to a database, or doing any other type of I/O (input/output) operation.

Language features such as async, await, and promise make writing asynchronous code in JavaScript easier. They allow you to write asynchronous code in a way that looks and behaves like synchronous code, making it easier to read and understand.

Let’s briefly look at the differences between callbacks, promises, and async/await before diving into each in more depth:

// using callbacks
function makeRequest(url, callback) {
  fetch(url)
    .then(response => response.json())
    .then(data => callback(null, data))
    .catch(error => callback(error));
}

makeRequest('http://example.com/', (error, data) => {
  if (error) {
    console.error(error);
  } else {
    console.log(data);
  }
});

In the first example, the makeRequest function uses a callback to return the result of the network request. The caller passes a callback function to makeRequest, which is called back with either the result(data) or an error:

// using promises
function makeRequest(url) {
  return new Promise((resolve, reject) => {
    fetch(url)
      .then(response => response.json())
      .then(data => resolve(data))
      .catch(error => reject(error));
  });
}

makeRequest('http://example.com/')
  .then(data => console.log(data))
  .catch(error => console.error(error));

In the second example, the makeRequest function returns a promise that resolves with the result of the network request or rejects with an error. The caller can use the then and catch methods on the returned promise to handle the result of the request:

// using async/await
async function makeRequest(url) {
  try {
    const response = await fetch(url);
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

makeRequest('http://example.com/');

In the third example, the makeRequest function is declared with the async keyword, which allows it to use the await keyword to wait for the result of the network request. The caller can use the try and catch keywords to handle any errors that may occur during the execution of the function.

Background

Callback functions in JavaScript can be passed to another function as an argument and executed after some asynchronous operation is completed. Callbacks were commonly used to handle the results of asynchronous operations, such as network requests or user input.

One of the main disadvantages of using callbacks is that they can lead to what is known as “callback hell”—a situation where nested callbacks become challenging to read and maintain. Consider the following example:

function makeRequest1(url, callback) {
  // make network request
  callback(null, response);
}

function makeRequest2(url, callback) {
  // make network request
  callback(null, response);
}

function makeRequest3(url, callback) {
  // make network request
  callback(null, response);
}

makeRequest1('http://example.com/1', (error, data1) => {
  if (error) {
    console.error(error);
    return;
  }

  makeRequest2('http://example.com/2', (error, data2) => {
    if (error) {
      console.error(error);
      return;
    }

    makeRequest3('http://example.com/3', (error, data3) => {
      if (error) {
        console.error(error);
        return;
      }

      // do something with data1, data2, data3
    });
  });
});

In this example, the makeRequest1 function makes a network request and then calls the callback function with the result of the request. The callback function then makes a second network request using the makeRequest2 function, which calls another callback function with its result. This pattern continues for the third network request.

Promise Patterns

Promises are a more modern approach to handling asynchronous operations in JavaScript. A promise is an object that represents the result of an asynchronous operation. It can be in three states: pending, fulfilled, or rejected. A promise is like a contract that can be settled if it is fulfilled or rejected.

You can create a promise using the Promise constructor, which takes a function as an argument. The function receives two arguments: resolve and reject. The resolve function is called when the asynchronous operation is completed successfully, and the reject function is called if the operation fails.

Here is an example that shows how you can use promises to make network requests:

function makeRequest(url) {
  return new Promise((resolve, reject) => {
    fetch(url)
      .then(response => response.json())
      .then(data => resolve(data))
      .catch(error => reject(error));
  });
}

makeRequest('http://example.com/')
  .then(data => console.log(data))
  .catch(error => console.error(error));

In this example, the makeRequest function returns a promise representing the network request’s result. The fetch method is used inside the function to make the HTTP request. If the request succeeds, the promise is fulfilled with the data from the response. If it fails, the promise is rejected with the error. The caller can use the then and catch methods on the returned promise to handle the result of the request.

One of the main advantages of using promises over callbacks is that they provide a more structured and readable approach to handling asynchronous operations. This allows you to avoid “callback hell” and write code that is easier to understand and maintain.

The following sections provide additional examples that will be relevant to help you understand the different promise design patterns that you can use in JavaScript.

Promise Chaining

This pattern allows you to chain multiple promises together to create more complex async logic:

function makeRequest(url) {
  return new Promise((resolve, reject) => {
    fetch(url)
      .then(response => response.json())
      .then(data => resolve(data))
      .catch(error => reject(error));
  });
}

function processData(data) {
  // process data
  return processedData;
}

makeRequest('http://example.com/')
  .then(data => processData(data))
  .then(processedData => console.log(processedData))
  .catch(error => console.error(error));

Promise Error Handling

This pattern uses the catch method to handle errors that may occur during the execution of a promise chain:

makeRequest('http://example.com/')
  .then(data => processData(data))
  .then(processedData => console.log(processedData))
  .catch(error => console.error(error));

Promise Parallelism

This pattern allows you to run multiple promises concurrently using the Promise.all method:

Promise.all([
  makeRequest('http://example.com/1'),
  makeRequest('http://example.com/2')
]).then(([data1, data2]) => {
  console.log(data1, data2);
});

Promise Sequential Execution

This pattern allows you to run promises in sequence using the Promise.resolve method:

Promise.resolve()
  .then(() => makeRequest1())
  .then(() => makeRequest2())
  .then(() => makeRequest3())
  .then(() => {
    // all requests completed
  });

Promise Memoization

This pattern uses a cache to store the results of promise function calls, allowing you to avoid making duplicate requests:

const cache = new Map();

function memoizedMakeRequest(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }

  return new Promise((resolve, reject) => {
    fetch(url)
      .then(response => response.json())
      .then(data => {
        cache.set(url, data);
        resolve(data);
      })
      .catch(error => reject(error));
  });
}

In this example, we’ll demonstrate how to use the memoizedMakeRequest function to avoid making duplicate requests:

const button = document.querySelector('button');
button.addEventListener('click', () => {
  memoizedMakeRequest('http://example.com/')
    .then(data => console.log(data))
    .catch(error => console.error(error));
});

Now, when the button is clicked, the memoizedMakeRequest function will be called. If the requested URL is already in the cache, the cached data will be returned. Otherwise, a new request will be made, and the result will be cached for future requests.

Promise Pipeline

This pattern uses promises and functional programming techniques to create a pipeline of async transformations:

function transform1(data) {
  // transform data
  return transformedData;
}

function transform2(data) {
  // transform data
  return transformedData;
}

makeRequest('http://example.com/')
  .then(data => pipeline(data)
    .then(transform1)
    .then(transform2))
  .then(transformedData => console.log(transformedData))
  .catch(error => console.error(error));

Promise Retry

This pattern allows you to retry a promise if it fails:

function makeRequestWithRetry(url) {
  let attempts = 0;

  const makeRequest = () => new Promise((resolve, reject) => {
    fetch(url)
      .then(response => response.json())
      .then(data => resolve(data))
      .catch(error => reject(error));
  });

  const retry = error => {
    attempts++;
    if (attempts >= 3) {
      throw new Error('Request failed after 3 attempts.');
    }
    console.log(`Retrying request: attempt ${attempts}`);
    return makeRequest();
  };

  return makeRequest().catch(retry);
}

Promise Decorator

This pattern uses a higher-order function to create a decorator that can be applied to promises to add additional behavior:

function logger(fn) {
  return function (...args) {
    console.log('Starting function...');
    return fn(...args).then(result => {
      console.log('Function completed.');
      return result;
    });
  };
}

const makeRequestWithLogger = logger(makeRequest);

makeRequestWithLogger('http://example.com/')
  .then(data => console.log(data))
  .catch(error => console.error(error));

Promise Race

This pattern allows you to run multiple promises concurrently and return the result of the first one to settle:

Promise.race([
  makeRequest('http://example.com/1'),
  makeRequest('http://example.com/2')
]).then(data => {
  console.log(data);
});

async/await Patterns

async/await is a language feature that allows a programmer to write asynchronous code as if it were synchronous. It is built on top of promises, and it makes working with asynchronous code easier and cleaner.

Here is an example of how you might use async/await to make an asynchronous HTTP request:

async function makeRequest() {
  try {
    const response = await fetch('http://example.com/');
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

In this example, the makeRequest function is asynchronous because it uses the async keyword. Inside the function, the await keyword is used to pause the execution of the function until the fetch call resolves. If the call succeeds, the data is logged to the console. If it fails, the error is caught and logged to the console.

Let us now look at some other patterns using async.

async Function Composition

This pattern involves composing multiple async functions together to create more complex async logic:

async function makeRequest(url) {
  const response = await fetch(url);
  const data = await response.json();
  return data;
}


async function processData(data) {
  // process data
  return processedData;
}

async function main() {
  const data = await makeRequest('http://example.com/');
  const processedData = await processData(data);
  console.log(processedData);
}

async Iteration

This pattern allows you to use the for-await-of loop to iterate over an async iterable:

async function* createAsyncIterable() {
  yield 1;
  yield 2;
  yield 3;
}

async function main() {
  for await (const value of createAsyncIterable()) {
    console.log(value);
  }
}

async Error Handling

This pattern uses try-catch blocks to handle errors that may occur during the execution of an async function:

async function main() {
  try {
    const data = await makeRequest('http://example.com/');
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

async Parallelism

This pattern allows you to run multiple async tasks concurrently using the Promise.all method:

async function main() {
  const [data1, data2] = await Promise.all([
    makeRequest('http://example.com/1'),
    makeRequest('http://example.com/2')
  ]);

  console.log(data1, data2);
}

async Sequential Execution

This pattern allows you to run async tasks in sequence using the Promise.resolve method:

async function main() {
  let result = await Promise.resolve();

  result = await makeRequest1(result);
  result = await makeRequest2(result);
  result = await makeRequest3(result);

  console.log(result);
}

async Memoization

This pattern uses a cache to store the results of async function calls, allowing you to avoid making duplicate requests:

const cache = new Map();

async function memoizedMakeRequest(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }

  const response = await fetch(url);
  const data = await response.json();

  cache.set(url, data);
  return data;
}

async Event Handling

This pattern allows you to use async functions to handle events:

const button = document.querySelector('button');

async function handleClick() {
  const response = await makeRequest('http://example.com/');
  console.log(response);
}

button.addEventListener('click', handleClick);

async/await Pipeline

This pattern uses async/await and functional programming techniques to create a pipeline of async transformations:

async function transform1(data) {
  // transform data
  return transformedData;
}

async function transform2(data) {
  // transform data
  return transformedData;
}

async function main() {
  const data = await makeRequest('http://example.com/');
  const transformedData = await pipeline(data)
    .then(transform1)
    .then(transform2);

  console.log(transformedData);
}

async Retry

This pattern allows you to retry an async operation if it fails:

async function makeRequestWithRetry(url) {
  let attempts = 0;

  while (attempts < 3) {
    try {
      const response = await fetch(url);
      const data = await response.json();
      return data;
    } catch (error) {
      attempts++;
      console.log(`Retrying request: attempt ${attempts}`);
    }
  }

  throw new Error('Request failed after 3 attempts.');
}

async/await Decorator

This pattern uses a higher-order function to create a decorator that can be applied to async functions to add additional behavior:

function asyncLogger(fn) {
  return async function (...args) {
    console.log('Starting async function...');
    const result = await fn(...args);
    console.log('Async function completed.');
    return result;
  };
}

@asyncLogger
async function main() {
  const data = await makeRequest('http://example.com/');
  console.log(data);
}

Additional Practical Examples

In addition to the patterns discussed in the previous sections, let’s take a look at some practical examples of using async/await in JavaScript.

Making an HTTP Request

async function makeRequest(url) {
  try {
    const response = await fetch(url);
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

Reading a File from the Filesystem

async function readFile(filePath) {
  try {
    const fileData = await fs.promises.readFile(filePath);
    console.log(fileData);
  } catch (error) {
    console.error(error);
  }
}

Writing to a File on the Filesystem

async function writeFile(filePath, data) {
  try {
    await fs.promises.writeFile(filePath, data);
    console.log('File written successfully.');
  } catch (error) {
    console.error(error);
  }
}

Executing Multiple async Operations

async function main() {
  try {
    const [data1, data2] = await Promise.all([
      makeRequest1(),
      makeRequest2()
    ]);
    console.log(data1, data2);
  } catch (error) {
    console.error(error);
  }
}

Executing Multiple async Operations in Sequence

async function main() {
  try {
    const data1 = await makeRequest1();
    const data2 = await makeRequest2();
    console.log(data1, data2);
  } catch (error) {
    console.error(error);
  }
}

Caching the Result of an async Operation

const cache = new Map();

async function makeRequest(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }

  try {
    const response = await fetch(url);
    const data = await response.json();
    cache.set(url, data);
    return data;
  } catch (error) {
    throw error;
  }
}

Handling Events with async/await

const button = document.querySelector('button');

button.addEventListener('click', async () => {
  try {
    const data = await makeRequest('http://example.com/');
    console.log(data);
  } catch (error) {
    console.error(error);
  }
});

Retrying an async Operation on Failure

async function makeRequest(url) {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return data;
  } catch (error) {
    throw error;
  }
}

async function retry(fn, maxRetries = 3, retryDelay = 1000) {
  let retries = 0;

  while (retries <= maxRetries) {
    try {
      return await fn();
    } catch (error) {
      retries++;
      console.error(error);
      await new Promise(resolve => setTimeout(resolve, retryDelay));
    }
  }

  throw new Error(`Failed after ${retries} retries.`);
}

retry(() => makeRequest('http://example.com/')).then(data => {
  console.log(data);
});

Creating an async/await Decorator

function asyncDecorator(fn) {
  return async function(...args) {
    try {
      return await fn(...args);
    } catch (error) {
      throw error;
    }
  };
}
const makeRequest = asyncDecorator(async function(url) {
  const response = await fetch(url);
  const data = await response.json();
  return data;
});

makeRequest('http://example.com/').then(data => {
  console.log(data);
});

Summary

This chapter covered an extensive set of patterns and examples that can be useful when writing asynchronous code for executing long-running tasks in the background. We saw how callback functions made way for promises and async/await to execute one or many async tasks.

In the next chapter, we will look at another angle of application architecture patterns. We will look at how the patterns for modular development have evolved over time.