Working with Standard Promises - JavaScript with Promises (2015)

JavaScript with Promises (2015)

Chapter 3. Working with Standard Promises

We’ve covered the standard Promise API and some basic scenarios, but like any technology, that’s only part of the story. Now it’s time for scenarios you’ll encounter and techniques you can use while writing real-world applications.

The Async Ripple Effect

Async functions and promises are contagious. After you start using them they naturally spread through your code. When you have one async function, any code that calls that function now contains an async step. The process of other functions becoming async by extension creates a ripple effect that continues all the way through the call stack. This is shown in Example 3-1 using three functions. Look how the async ajax function forces the other functions to also be async.

Example 3-1. The async ripple effect

showPun().then(function () {

console.log('Maybe I should stick to programming');

});

function showPun() {

return getPun().then(function (pun) {

console.log(pun);

});

}

function getPun() {

// Assume ajax() returns a promise that is eventually

// fulfilled by json for {content: 'The pet store job was ruff!'}

return ajax(/*someurl*/).then(function (json) {

var pun = JSON.parse(json);

return pun.content;

});

}

// Console output:

// The pet store job was ruff!

// Maybe I should stick to programming

The work to retrieve and display a pun is divided into three functions: showPun, getPun, and ajax. The functions form a chain of promises that starts with ajax and ends with the object returned by showPun. The ajax function returns a promise representing the result of an async XHR request. If ajax returned the JSON synchronously, getPun and showPun would not consume or return promises.

As a general rule, any function that uses a promise should also return a promise. When a promise is not propagated, the calling code cannot know when the promise is fulfilled and thus cannot effectively perform work after the fact. It’s easy to ignore this rule when writing a function whose caller does not care when the async work is finished, but don’t be fooled. It’s much easier to return a promise that initially goes unused than to retrofit promises into a series of functions later on.

Conditional Logic

It’s common to have a workflow that contains a conditional step. For instance, some actions may require user authentication. However, once a user is authenticated, he does not need to repeat that step every time the action is taken.

As an example, we’ll use an electronic book reader that requires authentication before the user can access any other features. There are multiple ways to code this scenario. Example 3-2 shows a first pass.

Example 3-2. Conditional async step

var user = {

authenticated: false,

login: function () {

// Returns a promise for the login request

// Set authenticated to true and fulfill promise when login succeeds

}

};

// Avoid this style of conditional async execution

function showMainMenu() {

if (!user.authenticated) {

user.login().then(showMainMenu);

return;

}

// ... Code to display main menu

};

In this implementation of showMainMenu, the menu is displayed immediately if the user is already authenticated. If the user is not authenticated, the async login process is performed and showMenu is run again once the login succeeds.

One problem here is that the menu will silently fail to display if the login process fails. That’s because showMainMenu relies on a promise but does not return a promise as described in the preceding section.

A second problem is that showMainMenu may behave synchronously or asynchronously depending on whether the user is already authenticated. As described in Chapter 1, this style of code creates multiple execution paths that can be difficult to reason about and create inconsistent behavior.

As shown in Example 3-3, the issues in showMainMenu can be addressed by substituting a resolved promise if the user is already authenticated.

Example 3-3. Substituting a resolved promise

function showMainMenu() {

var p = (!user.authenticated) ? user.login() : Promise.resolve();

return p.then(function () {

// ... Code to display main menu

});

}

Now the menu is always displayed asynchronously using either the promise that user.login() returned or a resolved promise substituted for the login process.

You can eliminate the need for a substitute promise by calling user.login() every time, as shown in Example 3-4.

Example 3-4. Encapsulating conditional logic with a promise

function showMainMenu() {

return user.login().then(function () {

// ... Code to display main menu

});

}

This doesn’t mean all the login steps need to be repeated every time. The promise that login returned can be cached and reused, as shown in Example 3-5.

Example 3-5. Caching a promise

var user = {

loginPromise: null,

login: function () {

var me = this;

if (this.loginPromise == null) {

this.loginPromise = ajax(/*someurl*/);

// Remove cached loginPromise when a failure occurs to allow retry

this.loginPromise.catch(function () {

me.loginPromise = null;

});

}

return this.loginPromise;

}

};

In Example 3-5, the loginPromise is created the first time login is called. All subsequent calls to login return the same promise as long as the login process does not fail. In case of failure, the cached promise is removed so the process can be retried.

Parallel Execution

Multiple asynchronous tasks can be run in parallel, as shown in Example 3-6. Consider a financial website that shows an updated balance for all your bank accounts and credit cards each time you log in. The updated balance from each institution can be requested in parallel and displayed as soon as it is received.

Example 3-6. Running asynchronous tasks in parallel

// Define each account

var accounts = ['Checking Account', 'Travel Rewards Card', 'Big Box Retail Card'];

console.log('Updating balance information...');

accounts.forEach(function (account) {

// ajax() returns a promise eventually fulfilled by the account balance

ajax(/*someurl for account*/).then(function (balance) {

console.log(account + ' Balance: ' + balance);

});

});

// Console output:

// Updating balance information...

// Checking Account Balance: 384

// Travel Rewards Card Balance: 509

// Big Box Retail Card Balance: 0

Promises are also good for consolidating parallel tasks into a single outcome. Suppose a message should be displayed informing the user when all the account balances are up-to-date. You can create a consolidated promise using the Promise.all() function that maps promises to their outcomes, as explained in Example 3-7. A full description of this function is provided in “The Promise API”. In short, Promise.all() returns a new promise that is fulfilled when all the promises it receives are fulfilled. And if any of the promises it receives get rejected, the new promise is also rejected.

Example 3-7. Consolidating the outcomes of parallel tasks with Promise.all()

var requests = accounts.map(function (account) {

return ajax(/*someurl for account*/);

});

// Update status message once all requests are fulfilled

Promise.all(requests).then(function (balances) {

console.log('All ' + balances.length + ' balances are up to date');

}).catch(function (error) {

console.log('An error occurred while retrieving balance information');

console.log(error);

});

// Console output:

// All 3 balances are up to date

Instead of looping through the accounts using forEach, the map function is used to create an array of promises representing a balance request for each account. Promise.all() then consolidates the promises into a single promise. An array containing all the account balances resolves the consolidated promise. In this example the length property of that array is used to display the number of balances retrieved.

You can also wait for all the operations represented by some promises to settle regardless of whether they succeeded or failed. In Example 3-8, let’s revise Example 3-7 to show the number of balances that were updated even if some requests failed.

Example 3-8. Running code after multiple operations have finished, regardless of their outcome

function settled(promises) {

var alwaysFulfilled = promises.map(function (p) {

return p.then(

function onFulfilled(value) {

return { state: 'fulfilled', value: value };

},

function onRejected(reason) {

return { state: 'rejected', reason: reason };

}

);

});

return Promise.all(alwaysFulfilled);

}

// Update status message once all requests finish

settled(requests).then(function (outcomes) {

var count = 0;

outcomes.forEach(function (outcome) {

if (outcome.state == 'fulfilled') count++;

});

console.log(count + ' out of ' + outcomes.length + ' balances were updated');

});

// Console output (varies based on requests):

// 2 out of 3 balances were updated

The settled function consolidates an array of promises into a single promise that is fulfilled once all the promises in the array are settled. An array of objects that indicate the outcome of each promise fulfills the new promise. In this example, the array of outcomes is reduced1 to a single value representing the number of requests that succeeded.2

Sequential Execution Using Loops or Recursion

You can dynamically build a chain of promises to run tasks in sequential order (i.e., each task must wait for the preceding task to finish before it begins.) Most of the examples so far have demonstrated sequential chains of then calls built with a pre-defined number of steps. But it is common to have an array where each item requires its own async task, like the code in Example 3-6 that looped through an array of accounts to get the balance for each one. Those balances were retrieved in parallel but there are times when you want to run tasks serially. For instance, if each task requires significant bandwidth or computation, you may want to throttle the amount of work being done.

Before building a sequential chain, let’s start with code that runs a set of tasks in parallel, as shown in Example 3-9, based on the items in an array similar to Example 3-6.

Example 3-9. Running tasks in parallel using a loop

var products = ['sku-1', 'sku-2', 'sku-3'];

products.forEach(function (sku) {

getInfo(sku).then(function (info) {

console.log(info)

});

});

function getInfo(sku) {

console.log('Requested info for ' + sku);

return ajax(/*someurl for sku*/);

}

// Console output:

// Requested info for sku-1

// Requested info for sku-2

// Requested info for sku-3

// Info for sku-1

// Info for sku-2

// Info for sku-3

This code iterates through an array of products and calls the getInfo function for each one. The beginning of each request is logged to the console inside getInfo and the outcome of each request is logged inside the loop after completing the request. You can see the requests are run in parallel because the code inside the forEach does not use any promises that previous iterations of the loop created. The order of the console output also demonstrates the parallel nature of the code. All three requests are made before the first result is received.

Let’s move from parallel tasks to sequential chains. The code we’ll use to do that can be daunting if you are unfamiliar with the array reduce function, which distills the elements of an array to a single value. Example 3-10 provides a snippet to serve as an introduction/refresher on howreduce is used.

Example 3-10. Overview of array.reduce

finalResult = array.reduce(function (previousValue, currentValue) {

// Create a result using the previousValue and currentValue

// return the result which will be used as the previousValue in the next loop

return previousValue + currentValue;

}, initialValue) // Used with first element

The reduce function accepts a callback that is invoked for each element in the array. It receives the previous value returned from the callback and the current element in the array. The previous value in the callback is seeded with an initial value the first time the callback is invoked. The return value for reduce is whatever the callback returns when it is invoked for the last element in the array.

Example 3-11 uses reduce to calculate the sum of all the numbers in an array.

Example 3-11. Simple array.reduce to sum numbers

var numbers = [2, 4, 6];

var sum = numbers.reduce(function (sum, number) {

return sum + number;

}, 0);

console.log(sum);

// Console output:

// 12

You could write some code with a for loop that would accomplish the same thing as reduce but it would be clunky by comparison. Now let’s get back to running tasks sequentially using reduce.

Example 3-12 uses the same products array and getInfo function from earlier code to request and display information. However, no request is started until the previous one completes. Although this could be done by calling the reduce function on products directly, the logic has been abstracted into a function called sequence.

Example 3-12. Build a sequential chain using a loop

// Build a sequential chain of promises from the elements in an array

function sequence(array, callback) {

return array.reduce(function chain(promise, item) {

return promise.then(function () {

return callback(item);

});

}, Promise.resolve());

};

var products = ['sku-1', 'sku-2', 'sku-3'];

sequence(products, function (sku) {

return getInfo(sku).then(function (info) {

console.log(info)

});

}).catch(function (reason) {

console.log(reason);

});

function getInfo(sku) {

console.log('Requested info for ' + sku);

return ajax(/*someurl for sku*/);

}

// Console output:

// Requested info for sku-1

// Info for sku-1

// Requested info for sku-2

// Info for sku-2

// Requested info for sku-3

// Info for sku-3

Skip the implementation of sequence for a moment and look at how it is used. An array of products is passed in along with a callback that is invoked once for each product in the array. If the callback returns a promise, the next callback is not invoked until that promise is fulfilled. The console output shows that the requests are run sequentially.

The sequence function encapsulates the details of chaining promises to dynamically sequence tasks. It iterates over the array by calling reduce and seeding the previous value with a resolved promise. The chain function given to reduce always returns a promise that the return value of the callback passed into sequence resolves. The cycle continues for each element until exhausting the array and returning the last promise in the chain. The calling code attaches a catch handler to that promise to conveniently handle any problems.

You can also construct a sequence of tasks from a list using recursion by replacing the previous sequence implementation in Example 3-12 with the code in Example 3-13.

Example 3-13. Build sequential chain using recursion

// Replaces sequence in previous example with a recursive implementation

function sequence(array, callback) {

function chain(array, index) {

if (index == array.length) return Promise.resolve();

return Promise.resolve(callback(array[index])).then(function () {

return chain(array, index + 1);

});

}

return chain(array, 0);

};

// Console output is identical to the previous example

Here the reduce function from earlier is replaced by chain, which recursively calls itself for each element in the array. A risk in recursive programming is creating a stack overflow by making too many recursive calls in a row. Fortunately, that does not occur here because a separate turn of the event loop invokes the promise callbacks, so each recursive call to chain is at the top of the call stack.

Although using recursion in Example 3-12 has the same final outcome as building the chain with a loop, there is an interesting difference between the two approaches. Using a loop builds the entire chain of promises at the outset without waiting for any of the promises to be resolved. The recursive approach adds to the chain on demand after resolving the preceding promise. A major benefit of the on-demand approach is the ability to decide whether to continue chaining promises based on the result from the preceding promise.

The last few examples have made a chain with a predefined number of steps based on the elements in an array. With recursion you can build a chain whose length is not determined in advance, as shown in Example 3-14. A great example for this case is included in the WHATWG Streams specification for performing I/O. The spec contains sample code for sequentially reading all the data from a stream in a series of chunks. Each call to read returns a promise fulfilled by an object with a value property containing a chunk of data and a done property indicating when the stream is exhausted.3

Example 3-14. Conditionally expanding a chain based on the outcome of a preceding promise

function readAllChunks(readableStream) {

var reader = readableStream.getReader();

var chunks = [];

return pump();

function pump() {

return reader.read().then(function (result) {

if (result.done) {

return chunks;

}

chunks.push(result.value);

return pump();

});

};

}

Here the pump function appends each chunk of data to an array and recursively calls itself until result.done signals there is no more data available.

You may not have an immediate need for building sequential promise chains, but it will inevitably occur. If you use a library to supplement standard promises, this functionality may be included. Libraries are discussed in more detail in Chapter 4.

WARNING

Building long chains of promises may require significant amounts of memory. Be sure to instrument and test your code to guard against unexpected performance problems.

Managing Latency

When you have a promise that wraps an asynchronous network request, how long should you wait for the promise to settle? Although you may expect a quick response, the actual time is based on many factors outside the control of your code. You can prevent your application from entering a state of prolonged or endless waiting by enforcing a time limit.

The getData function in Example 3-15 returns a promise fulfilled by fresh data fetched from a server. It concurrently pulls existing data from a cache to use in case the server does not respond quickly enough. And if neither the server nor the cache respond in time, the promise returned bygetData is rejected. Each of the outcomes is represented by a promise. The code uses Promise.race() to select the first available outcome.

Example 3-15. Manage response time using Promise.race()

function getData() {

var timeAllowed = 500; // milliseconds

var deadline = Date.now() + timeAllowed;

var freshData = ajax(/*someurl*/);

var cachedData = fetchFromCache().then(function (data) {

return new Promise(function (resolve) {

var timeRemaining = Math.max(deadline - Date.now(), 0);

setTimeout(function () {

resolve(data);

}, timeRemaining);

});

});

var failure = new Promise(function (resolve, reject) {

setTimeout(function () {

reject(new Error('Unable to fetch data in allotted time'));

}, timeAllowed);

});

return Promise.race([freshData, cachedData, failure]);

}

Some scenarios were omitted from the preceding example so that they would not detract from the point. For instance, if the network request fails quickly, the promise returned from getData will be rejected immediately. In this case you may still want to use the cached data if it is retrieved within the allotted time. Reactive programming libraries such as RxJS, Bacon.js, and Kefir.js are specifically intended for scenarios like this.

Functional Composition

Earlier in the book you saw how promise chains are useful in orchestrating a series of async steps. The same pattern is also good for building pipelines of functions. This technique of combining several basic functions into a more powerful composite is known as functional composition, and it divides code into discrete units that are easier to test and maintain.

Let’s use a website for a large real estate agency in Example 3-16. Each agent in the company has a web page with her picture and contact information. All the profile photos are displayed in the same size in black and white and include the company name. You can create a pipeline that processes images for display on the site.

Example 3-16. Verbose pipeline

// Generic image processing functions

function scaleToFit(width, height, image) {

console.log('Scaling image to ' + width + ' x ' + height);

return image;

}

function watermark(text, image) {

console.log('Watermarking image with ' + text);

return image;

}

function grayscale(image) {

console.log('Converting image to grayscale');

return image;

}

// Image processing pipeline

function processImage(image) {

return Promise.resolve(image).then(function (image) {

return scaleToFit(300, 450, image);

}).then(function (image) {

return watermark('The Real Estate Company', image);

}).then(function (image) {

return grayscale(image);

});

}

// Console output for processImage():

// Scaling image to 300 x 450

// Watermarking image with The Real Estate Company

// Converting image to grayscale

The image processing functions in this example are all generic. They have no knowledge of the real estate website and could easily exist in a third-party library. The processImage function containing the pipeline is the only thing with domain-specific knowledge. It composes the three functions in the required order and provides the necessary parameters.

The processImage function can be shortened, as shown in Example 3-17, by replacing the traditional-looking promise chain with a chain of functions preconfigured with the necessary parameters for width, height, and watermark text.

Example 3-17. Concise pipeline

// Replaces processImage in previous example

function processImage(image) {

// Image is always last parameter preceded by any configuration parameters

var customScaleToFit = scaleToFit.bind(null, 300, 450);

var customWatermark = watermark.bind(null, 'The Real Estate Company');

return Promise.resolve(image)

.then(customScaleToFit)

.then(customWatermark)

.then(grayscale);

}

The pipeline is succinctly written at the end of processImage. The code works because each of the functions that manipulate the image take it as the last parameter, allowing the width, height, and watermark parameters to be bound in advance.

Using promise chains in this manner does not require the individual functions to be async. However, it does allow any of the functions in the chain to change from synchronous to asynchronous later without affecting the calling code. Just avoid overkill with this approach by using it whenever you can, as opposed to only when you should. For example, you may not need a chain of promises when a call to the built-in map or filter array functions will do.

Summary

This chapter covered a number of scenarios that are likely to arise when using promises. It showed how one async function affects all the functions that come before it in the call stack. It also showed how to process an arbitrary number of tasks sequentially or in parallel. And how to build processing pipelines by chaining promises together.

All the topics in this chapter were addressed using the standard Promise API. This discussion is continued in Chapter 4 using expanded APIs that some promise libraries and frameworks provided.

1 It is natural to use outcomes.reduce() in place of outcomes.forEach() in this example; however, some readers may be unfamiliar with reduce, so it is not used until it is explained in the next section (see Example 3-10).

2 The settled function is based on a similar function in the Bluebird library.

3 The code in the spec uses object destructuring with an arrow function, which has been replaced by a traditional function declaration here. Destructuring and arrow functions are discussed in Chapter 6.