DEV Community

Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Improve async programming with JavaScript promises

If you’ve written some asynchronous JavaScript code before, then you already have an idea about using callbacks and the issues with them. One major issue with using callbacks is the possibility of running into a callback hell.

In ES6, JavaScript Promises were added to the language spec, bringing about an entirely new shift in how asynchronous code is written, and also mitigating the issue of running into a callback hell. If you are using ES6 syntax in your code, you may already be familiar with Promises.

In this guide, you will see some practical ways to improve asynchronous programming in JavaScript using promises.

This guide is not in anyway an introduction to JavaScript promises. Some prior knowledge of Promises is required to read this guide.

Creating promises

A JavaScript promise can be created using the Promise constructor. The constructor function takes an executor function as its argument which is immediately executed to create the promise.

The executor in turn, can take two callback functions as its arguments, that can be invoked within the executor function in order to settle the promise, namely:

  • resolve for fulfilling the promise with a value
  • reject for rejecting the promise with a reason (usually an error)

Here is a very simple JavaScript promise that is settled after loading an image:

const loadPhoto = photo_url =>
  new Promise((resolve, reject) => {
    // Create a new Image object
    const img = new Image();

    img.addEventListener('load', () => {
      // If the image loads,
      // fulfill the promise with the Image object
      resolve(img);
    }, false);

    img.addEventListener('error', () => {
      // If there was an error,
      // reject the promise with an error
      reject(new Error('Image could not be loaded.'));
    }, false);

    // Set the image source
    img.src = photo_url;
  });
Enter fullscreen mode Exit fullscreen mode

Here, you can see that the returned promise will be fulfilled with the img Image object if the image was loaded successfully. Otherwise, it will be rejected with an error indicating that the image could not be loaded.

Settled promises

Often times, you just want to create a promise that is already settled (either fulfilled with a value or rejected with a reason). For cases like this, the Promise.resolve() and Promise.reject() methods come in handy. Here is a simple example:

// This promise is already fulfilled with a number (100)
const fulfilledPromise = Promise.resolve(100);

// This promise is already rejected with an error
const rejectedPromise = Promise.reject(new Error('Operation failed.'));
Enter fullscreen mode Exit fullscreen mode

There could also be times when you are not sure if a value is a promise or not. For cases like this, you can use Promise.resolve() to create a fulfilled promise with the value and then work with the returned promise. Here is an example:

// User object
const USER = {
  name: 'Glad Chinda',
  country: 'Nigeria',
  job: 'Fullstack Engineer'
};

// Create a fulfilled promise using Promise.resolve()
Promise.resolve(USER)
  .then(user => console.log(user.name));
Enter fullscreen mode Exit fullscreen mode

By leveraging settled promises, you can modify the loadPhoto() promise factory from before like this:

const loadPhoto = photo_url =>
  new Promise(resolve => {
    const img = new Image();

    img.addEventListener('load', () => { 
      resolve(img);
    }, false);

    img.addEventListener('error', () => {
      const rejection = Promise.reject(new Error('Image could not be loaded.'));
      resolve(rejection);
    }, false);

    img.src = photo_url;
  });
Enter fullscreen mode Exit fullscreen mode

In this code snippet, two obvious changes have been made:

  • First, the promise executor function is defined with only one argument instead of two.
// BEFORE
new Promise((resolve, reject) => {...})

// NOW
new Promise(resolve => {...})
Enter fullscreen mode Exit fullscreen mode
  • Second, in the ‘error’ event listener, the promise is fulfilled by a settled promise (rejected with an error), instead of the former variant in which the promise is directly rejected with the error.
// BEFORE
reject(new Error('Image could not be loaded.'));

// NOW
const rejection = Promise.reject(new Error('Image could not be loaded.'));

resolve(rejection);
Enter fullscreen mode Exit fullscreen mode

It is important to note that the two code snippets still work in the same way — which can be attributed to the mechanism for creating intermediate promises during handling of the settled promise.

Handling promises

A settled promise can be handled by chaining callbacks to the then(), catch() or finally() methods of the promise.

The following code snippet demonstrates how you can handle the settled promise returned from the loadPhoto() function we created earlier:

loadPhoto('https://picsum.photos/g/800/600/?image=0&gravity=north')
  .then(console.log)
  .catch(console.error);

// Alternatively, the rejection can be handled in the same .then() call
// By passing the rejection handler as second argument to .then()
loadPhoto('https://picsum.photos/g/800/600/?image=0&gravity=north')
  .then(console.log, console.error);
Enter fullscreen mode Exit fullscreen mode

Since the returned promise is fulfilled with an Image object, you can do something more useful in the .then() handler callback, such as adding attributes to the image and appending the image to the document.

Here is an improved code snippet that does just that:

loadPhoto('https://picsum.photos/g/800/600/?image=0&gravity=north')
  .then(img => {
    // Set some image attributes
    img.alt = 'Random image from Picsum';
    img.classList = 'img img--fluid';

    // Append the image to the document
    document.body.appendChild(img);
  })
  .catch(console.error);
Enter fullscreen mode Exit fullscreen mode

The following CodePen shows a demo of this example:

Dumb then handlers

The .then() method can take up to two handler functions as its arguments: fulfillment handler and rejection handler respectively.

However, if any of these two arguments are not a function, .then() replaces that argument with a function and continues with the normal execution flow. It becomes important to know what kind of function the argument is replaced with. Here is what it is:

  • If the fulfillment handler argument is not a function, it is replaced with an Identity Function. An identity function is a function that simply returns the argument it receives
  • If the rejection handler argument is not a function, it is replaced with a Thrower Function. A thrower function is a function that simply throws the error or value it receives as its argument

If you observe carefully, you will notice that neither the identity function nor the thrower function alters the normal execution flow of the promise sequence.

They simply have the same effect as omitting that particular .then() call in the promise chain. For this reason, I usually refer to these handler arguments as “dumb handlers”.

Then handlers return promises

One important thing to understand about the .then() promise method is that it always returns a promise.

Here is a breakdown of how .then() returns a promise based on what is returned from the handler function is passed to it:

Timing with promises

Delaying execution

Promises can be very useful for timing applications. Some programming languages like PHP have a sleep() function that can be used to delay the execution of an operation until after the sleep time.

<?php

// Sleep for 5 seconds
sleep(5);

// Then execute this operation
App::start();
Enter fullscreen mode Exit fullscreen mode

While a sleep() function does not exist as part of the JavaScript spec, the global setTimeout() and setInterval() functions are commonly used for executing time-based operations.

Here is how the sleep() function can be simulated using promises in JavaScript. However, in this version of the sleep() function, the halt time will be in milliseconds instead of seconds:

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
Enter fullscreen mode Exit fullscreen mode

Here is a slightly expanded and annotated version of the sleep(ms) function:

const sleep = ms => {
  // Return a new promise
  // No need defining the executor function with a `reject` callback
  return new Promise(resolve => {
    // Pass resolve as the callback to setTimeout
    // This will execute `resolve()` after `ms` milliseconds
    setTimeout(resolve, ms);
  });
}
Enter fullscreen mode Exit fullscreen mode

The sleep(ms) function can even be improved further to become a self-contained delay function that executes a callback function after the specified sleep time.

Here is what using the sleep(ms) function could look like:

// Sleep for 5 seconds
// Then execute the operation
sleep(5000).then(executeOperation);


// Delay function
// Using async/await with sleep()
const delay = async (callback, seconds = 1) => {
  // Sleep for the specified seconds
  // Then execute the operation
  await sleep(seconds * 1000);
  callback();
}

// Using the `delay()` function
// Execution delayed by 5 seconds
delay(executeOperation, 5);
Enter fullscreen mode Exit fullscreen mode

Measuring execution time

What if you are interested in knowing how long it took for an asynchronous operation to be completed? This is usually the case when benchmarking the performance of some form of implementation or functionality.

Here is a simple implementation that leverages a JavaScript promise to compute the execution time for an asynchronous operation.

const timing = callback => {
  // Get the start time using performance.now()
  const start = performance.now();

  // Perform the asynchronous operation
  // Finally, log the time difference
  return Promise.resolve(callback())
    .finally(() => console.log(`Timing: ${performance.now() - start}`));
}
Enter fullscreen mode Exit fullscreen mode

In this implementation, performance.now() is used instead of Date.now() for getting the timestamp with a higher resolution. For non-browser environments where the performance object does not exist, you can fallback on usingDate.now() or other host implementations.

Here is how the timing() function could be used to log the execution time of an asynchronous operation on the console:

// Async operation that takes between 1 - 5 seconds
const asyncOperation = () => new Promise(resolve => {
  setTimeout(() => resolve('DONE'), Math.ceil(Math.random() * 5) * 1000);
});

// Compute execution time in ms
// And log it to the console
timing(asyncOperation); // Timing: 4003.4000000014203
Enter fullscreen mode Exit fullscreen mode

Sequential execution with promises

With JavaScript promises, you can execute asynchronous operations in sequence. This is usually the case when a later asynchronous operation depends on the execution of a former asynchronous operation, or when the result of a former asynchronous operation is required for a later operation.

Executing asynchronous operations in sequence usually involves chaining one or more .then() and .catch() handlers to a promise. When a promise is rejected in the chain, it is handled by the rejection handler defined in the next .then() handler in the chain and then execution continues down the chain.

However, if no rejection handler has been defined in the next .then() handler in the chain, the promise rejection is cascaded down the chain until it reaches the first .catch() handler.

Case study: photos collection

Let’s say you are building a photo gallery application and you want to be able to fetch photos from an online photo repository and filter the photos by format, aspect-ratio, dimension ranges, etc. Here are some possible functions you could have in your application:

/**
 * Fetches photos from the Picsum API
 * @returns {Promise} A promise that is fulfilled with an array of photos from the Picsum repository
 */
const fetchPhotos = () =>
  fetch('https://picsum.photos/list')
    .then(response => response.json());

/**
 * Filters photos and returns only JPEG photos 
 * @param {Array} photos
 * @returns {Array} An array of JPEG photos
 */
const jpegOnly = photos =>
  photos.filter(({ format }) => format.toLowerCase() === 'jpeg')

/**
 * Filters photos and returns only square photos
 * @param {Array} photos
 * @returns {Array} An array of square photos
 */
const squareOnly = photos =>
  photos.filter(({ width, height }) => height && Number.isFinite(height) && (width / height) === 1)

/**
 * Returns a function for filtering photos by size based on `px`
 * @param {number} px The maximum allowed photo dimension in pixels
 * @returns {Function} Function that filters photos and returns an array of photos smaller than `px`
 */
const smallerThan = px => photos =>
  photos.filter(({ width, height }) => Math.max(width, height) < px)

/**
 * Return an object containing the photos count and URLs.
 * @param {Array} photos
 * @returns {Object} An object containing the photos count and URLs
 */
const listPhotos = photos => ({
  count: photos.length,
  photos: photos.map(({ post_url }) => post_url)
})
Enter fullscreen mode Exit fullscreen mode

The fetchPhotos() function fetches a collection of photos from the Picsum Photos APIusing the global fetch() function provided by the Fetch API, and returns a promise that is fulfilled with a collection of photos.

Here is what the collection returned from the Picsum Photos API looks like:

The filter functions accept a collection of photos as an argument and filter the collection in some manner.

  • jpegOnly() — filters a photo collection and returns a sub-collection of only JPEG images
  • squareOnly() — filters a photo collection and returns a sub-collection of only photos with a square aspect-ratio
  • smallerThan() — function is a higher-order function that takes a dimension and returns a photos filter function that returns a sub-collection of photos that have their maximum dimensions below the specified dimension threshold

Let’s say we want to execute this sequence of operations:

  1. fetch the photos collection
  2. filter the collection leaving only JPEG photos
  3. filter the collection leaving only photos with square aspect-ratio
  4. filter the collection leaving only photos smaller than 2500px
  5. extract the photos count and URLs from the collection
  6. log the final output on the console
  7. log error to the console if an error occurred at any point in the sequence

The following code snippet shows how we can chain the execution of these operations in a promise sequence:

// Execute asynchronous operations in sequence
fetchPhotos()
  .then(jpegOnly)
  .then(squareOnly)
  .then(smallerThan(2500))
  .then(listPhotos)
  .then(console.log)
  .catch(console.error);
Enter fullscreen mode Exit fullscreen mode

The resulting output that is logged to the console looks like this:

Parallel execution with promises

With JavaScript promises, you can execute multiple independent asynchronous operations in batch or parallel using the Promise.all() method.

Promise.all() accepts an iterable of promises as its argument and returns a promise that is fulfilled when all the promises in the iterable are fulfilled or is rejected when one of the promises in the iterable is rejected.

If the returned promise fulfills, it is fulfilled with an array of all the values from the fulfilled promises in the iterable (in the same order). However, if it rejects, it is rejected because of the first promise in the iterable that rejected.

Case study: current temperatures

Let’s say you are building a weather application that allows users to see the current temperatures of a list of cities they’ve selected.

The following code snippet demonstrates how to fetch the current temperatures of the selected cities in parallel with Promise.all(). The OpenWeatherMap API service will be used to fetch the weather data:

// Use your OpenWeatherMap API KEY
// Set the current weather data API URL
const API_KEY = 'YOUR_API_KEY_HERE';
const API_URL = `https://api.openweathermap.org/data/2.5/weather?appid=${API_KEY}&units=metric`;

// Set the list of cities
const CITIES = [
  'London', 'Tokyo', 'Melbourne', 'Vancouver',
  'Lagos', 'Berlin', 'Paris', 'Johannesburg',
  'Chicago', 'Mumbai', 'Cairo', 'Beijing'
];

/**
 * Fetches the current temperature of a city (in °C).
 * @param {string} city The city to fetch its current temperature
 * @returns {Promise} A promise that is fulfilled with an array of the format [city, temp]
 */
const fetchTempForCity = city => {
  // Append the encoded city name to the API URL and make a request with `fetch()`
  // From the JSON response, get the value of the `main.temp` property
  // Return a promise that is fulfilled with an array of the format [city, temp]
  // For example: ['Lagos', 29.28]

  return fetch(`${API_URL}&q=${encodeURIComponent(city)}`)
    .then(response => response.json())
    .then(data => [ city, data.main.temp || null ]);
}

/**
 * Fetches the current temperatures of multiple cities (in °C).
 * @param {Array} cities The cities to fetch their current temperatures
 * @returns {Promise} A promise that is fulfilled with an object map of each city against temp
 */
const fetchTempForCities = cities => {
  // Use `Array.prototype.map()` to create a Promise for each city's current temperature.
  // Use `Promise.all()` to execute all the promises in parallel (as a single batch).
  // When all the promises have been settled, use `Array.prototype.reduce()` to construct
  // an object map of each city in the list of cities against its current temperature (in °C).
  // Return a promise that is fulfilled with the object map

  return Promise.all(cities.map(fetchTempForCity))
    .then(temps => {
      return temps.reduce((data, [ city, temp ]) => {
        return { ...data, [city]: Number.isFinite(temp) ? temp.toFixed(2) * 1 : null };
      }, {});
    });
}

// Fetch the current temperatures for all the cities listed in CITIES
// `console.log()` the data on fulfillment and `console.error()` on rejection
fetchTempForCities(CITIES)
  .then(console.log, console.error);
Enter fullscreen mode Exit fullscreen mode

That was one hell of a code snippet. The important parts to look out for are the fetchTempForCity() and fetchTempForCities() functions.

  • fetchTempForCity() — accepts a single city as its argument and returns a promise that is fulfilled with the current temperature of the given city (in °C) by calling the OpenWeatherMap API service. The returned promise is fulfilled with an array of the format: [city, temperature]
  • fetchTempForCities() — accepts an array of cities and fetches the current temperature of each city by leveraging Array.prototype.map() to call the fetchTempForCity() function on each city. Promise.all() is used to run the requests in parallel and accumulate their data into a single array, which in turn is reduced to an object using Array.prototype.reduce()

Running the above code snippet will log an object that looks like the following on the console:

Rejection handling

It is important to note — if any of the fetch temperature promises passed into Promise.all() is rejected with a reason, the entire promise batch will be rejected immediately with that same reason. That is, if at least 1 out of the 12 fetch temperature promises is rejected for some reason, the entire promise batch will be rejected and hence, no current temperatures.

The scenario described above is usually not the desired behavior — a failed temperature fetch should not cause the results of the successful fetches in the batch to be discarded.

A simple workaround for this will be to attach a .catch() handler to the fetchTempForCity promise, causing it to fulfill the promise with a null temperature value in cases of rejection.

Here is what this will look like:

const fetchTempForCity = city => {
  return fetch(`${API_URL}&q=${encodeURIComponent(city)}`)
    .then(response => response.json())
    .then(data => [ city, data.main.temp || null ])

    // Attach a `.catch()` handler for graceful rejection handling
    .catch(() => [ city, null ]);
}
Enter fullscreen mode Exit fullscreen mode

With that little change to the fetchTempForCity() function, there is now a very high guarantee that the returned promise will never be rejected in cases where the request fails or something goes wrong, rather it will be fulfilled with an array of the format: [city, null]

With this change, it becomes possible to further improve the code to be able to schedule retries for failed temperature fetches.

The following code snippet includes some additions that can be made to the previous code to make this possible.

// An object that will contain the current temperatures of the cities
// The keys are the city names, while the values are their current temperatures (in °C)
let TEMPS = null;

// The maximum number of retries for failed temperature fetches
const MAX_TEMP_FETCH_RETRIES = 5;

/**
 * Fetches the current temperatures of multiple cities (in °C) and update the `TEMPS` object.
 * Also schedule retries for failed temperature fetches.
 * @param {Array} cities The cities to fetch their current temperatures
 * @param {number} retries The current number of retries so far
 * @returns {Promise} A promise that is fulfilled with the updated `TEMPS` object.
 */
const fetchTemperatures = (cities, retries = 0) => {
  return fetchTempForCities(cities)
    .then(temps => {

      // Update the `TEMPS` object with updated city temperatures from `temps`
      TEMPS = (TEMPS === null) ? temps : { ...TEMPS, ...temps };

      // Filter the keys (cities) of the `TEMPS` object to get a list of the cities
      // with `null` temperature values.
      const RETRY_CITIES = Object.keys(TEMPS)
        .filter(city => TEMPS[city] == null);

      // If there are 1 or more cities in the `RETRY_CITIES` list
      // and the maximum retries has not been exceeded,
      // attempt to fetch their temperatures again after waiting for 5 seconds.
      // Also increment `retries` by 1.

      if (RETRY_CITIES.length > 0 && retries < MAX_TEMP_FETCH_RETRIES) {
        setTimeout(() => fetchTemperatures(RETRY_CITIES, ++retries), 5 * 1000);
      }

      // Return the updated `TEMPS` object
      return TEMPS;

    })
    .then(console.log, console.error);
}

// Fetch the current temperatures of the cities in the `CITIES` list
// and update the `TEMPS` object
fetchTemperatures(CITIES);
Enter fullscreen mode Exit fullscreen mode

In this code snippet, the TEMPS object is used to hold the updated temperatures of the listed cities. The MAX_TEMP_FETCH_RETRIES constant is an integer that limits the number of retries that can be done for failed fetches, which is five (5) in this case.

The fetchTemperatures() function receives an array of city names and the number of retries so far as its arguments. It calls fetchTempForCities() to fetch the current temperatures for the cities passed to it and also updates the TEMPS object with the temperatures.

For failed fetches, it schedules a call again to itself after waiting for 5 seconds and increments the retries count by 1. The retries are done for as many times as possible, provided the set maximum has not be exceeded — which is 5 in this case.

Racing with promises

With JavaScript Promises, you can race multiple independent asynchronous operations using the Promise.race() method. Promise.race() accepts an iterable of promises as its argument and returns a promise that is fulfilled or rejected in the same way as the first settled promise in the iterable.

If the first settled promise in the iterable is fulfilled with a value, the race promise is fulfilled with the same value. However, if it is rejected, the race promise will be rejected with the same reason. If multiple promises are fulfilled or rejected at the same time, then the first promise will be used based on the order of the promises in the iterable.

If the iterable passed to Promise.race() is empty, then the race promise remains pending forever and is never settled.

Case study: timeout response

Let’s say you are building an API endpoint that does some asynchronous operation like reading from a file or querying a database, and you want to guarantee that you get a response in 5 seconds — otherwise the request should fail with a HTTP 504 Gateway Timeout response.

The following code snippet demonstrates how Promise.race() can be used to achieve this, assuming we are building the API using the Express framework for Node.js.

// Create a new Express app and set the port
const app = require('express')();
const PORT = process.env.PORT || 5000;

// The timeout in seconds for API responses
const TIMEOUT_SECONDS = 5;

// Define a new route on the Express app: GET /random
app.get('/random', (req, res) => {

  /**
   * `execute` is a promise that simulates a time-consuming asynchronous operation
   * which may take anywhere between 1s - 10s to complete its execution.
   * On completion, it is fulfilled with an object that looks like this:
   * {
   *   statusCode: 200,
   *   random: (A random integer in the range of 0 - 100, both inclusive)
   *   duration: (The duration of the execution in seconds, expressed as {duration}s)
   * }
   */
  const execute = new Promise(resolve => {
    // Random execution time in milliseconds
    const timeInMs = Math.floor((Math.random() * 10) * 1000);

    // Simulate execution delay using setTimeout and fulfill the promise
    // with the response object
    setTimeout(() => {
      resolve({
        statusCode: 200,
        random: Math.floor(Math.random() * 101),
        duration: `${timeInMs / 1000}s`
      })
    }, timeInMs);
  });

  /**
   * `requestTimer` is a promise that is settled after `TIMEOUT_SECONDS` seconds
   * On completion, it is fulfilled with an object that looks like this:
   * { statusCode: 504 }
   * which represents a Gateway Timeout on the server.
   */
  const requestTimer = new Promise(resolve => {
    // Simulate execution delay using setTimeout and fulfill the promise
    // with the response object
    const timeoutInMs = TIMEOUT_SECONDS * 1000;
    setTimeout(() => resolve({ statusCode: 504 }), timeoutInMs);
  });

  /**
   * `Promise.race()` is used to run both the `execute` and the `requestTimer` promises.
   * The first of the two promises that gets settled will be used to settle the race promise.
   * The fulfilled response object is then used to form and send the HTTP response.
   * If an error occurs, a HTTP 500 error response is sent.
   */
  return Promise.race([ execute, requestTimer ])
    .then(({ statusCode = 200, ...data }) => {
      const response = res.status(statusCode);

      return (statusCode == 200)
        ? response.json(data)
        : response.end();
    })
    .catch(() => res.status(500).end());

});

// Start the app on the set port
app.listen(PORT, () => console.log(`App is running on port ${PORT}.`));
Enter fullscreen mode Exit fullscreen mode

In this code snippet, a very minimalistic Express application has been set up with a single route — GET /random for returning a randomly generated integer in the range of 0–100 (both inclusive), while also returning the execution time.

Promise.race() is used to wait for the first of two promises:

  • an execute promise that performs some seemingly time-consuming asynchronous operation and gets settled after 1s — 10s
  • a requestTimer promise that does nothing and gets settled after the set TIMEOUT_SECONDS seconds, which is 5 seconds in this case.

So here is what happens, whichever of these two promises that get settled first will determine the final response from the endpoint — Promise.race() will make sure of that.

A similar technique can also be used when handling fetch events in service workers to detect slow networks.

Conclusion

JavaScript promises can drastically change the way you go about writing asynchronous programs, making your codes more succinct and clearer in regards to the desired intent.

In this guide, you’ve seen several ways promises can be used in asynchronous programs like executing operations in sequence, in parallel and even racing them.

More recent versions of the ECMAScript specification even provide syntax additions to the language such as async functions, await keyword and even async iterators — making it easier and cleaner to work with JavaScript promises.

Clap & follow

If you found this article insightful, feel free to give some rounds of applause if you don’t mind — as that will help other people to easily find it on Medium.

You can also follow me on Medium (Glad Chinda) for more insightful articles you may find helpful. You can also follow me on Twitter (@gladchinda).

Enjoy coding…


Plug: LogRocket, a DVR for web apps

https://logrocket.com/signup/

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

Try it for free.


The post Improve async programming with JavaScript promises appeared first on LogRocket Blog.

Top comments (0)