The Challenges with JavaScript Promises

JavaScript
12 minutes read

Introduction

Hello developers! If you’re seeking to master asynchronous programming in JavaScript, understanding Promises is a must. These handy constructs make asynchronous code cleaner and more readable, but they come with their own set of quirks. Today, we’ll be diving into the world of Promises to uncover their potential pitfalls. Whether you’re a new developer or an experienced developer, there’s something to learn!

The Importance of Promises in JavaScript

So, why are Promises such a big deal in JavaScript? Let’s start with the problem Promises solve: the known “callback hell“. Without Promises, asynchronous JavaScript code can easily spiral into a deeply nested structure of callbacks, making it challenging to read and maintain.

Promises offer a more streamlined approach. They represent the eventual completion (or failure) of an asynchronous operation, and its resulting value. With Promises, we can chain asynchronous operations, handle errors more elegantly, and improve our control flow.

Let’s take an example to illustrate how Promises can help to streamline asynchronous code and prevent “callback hell”. Here’s a scenario where we want to fetch some data from a server, process it, and then save it. 

First, let’s demonstrate this with callbacks and then with Promises.

Using Callbacks

function fetchData(callback) {
    // Simulating data fetching
    setTimeout(() => {
        callback('Data fetched');
    }, 2000);
}

function processData(data, callback) {
    // Simulating data processing
    setTimeout(() => {
        callback(data + ' --> Data processed');
    }, 2000);
}

function saveData(data, callback) {
    // Simulating data saving
    setTimeout(() => {
        callback(data + ' --> Data saved');
    }, 2000);
}

// Using the functions
fetchData((fetchedData) => {
    processData(fetchedData, (processedData) => {
        saveData(processedData, (savedData) => {
            console.log(savedData);
        });
    });
})

In this example, we’re using callbacks to handle asynchronous operations. However, as we add more steps, the code becomes more nested and harder to follow. This is what “callback hell” means.

Using Promises

function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Data fetched');
        }, 2000);
    });
}

function processData(data) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(data + ' --> Data processed');
        }, 2000);
    });
}

function saveData(data) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(data + ' --> Data saved');
        }, 2000);
    });
}

// Using the functions
fetchData()
.then(fetchedData => return processData(fetchedData))
.then(processedData => return saveData(processedData))
.then(savedData => console.log(savedData))
.catch(error => console.error(error));

This is the same scenario implemented with Promises. The code is much more readable, and we’re not sinking into “callback hell“. It also allows us to add a .catch() clause to handle any errors that might occur at any step in the process. This shows the real strength of Promises in handling asynchronous operations in JavaScript.

Exploring the Drawbacks of Promises with Examples

Promises are useful, but, like all good things, Promises aren’t without their issues. Let’s dive into some common challenges you might encounter when working with Promises.

Error Handling

Handling errors effectively is a crucial part of developing robust applications. When working with Promises, it’s essential to understand how errors propagate and how you can catch them.

The issue with promises if a promise throw an error and don’t catch it, promises fail silently. In a synchronous block of code, an uncaught error would crash the program, providing immediate feedback about the problem. In an asynchronous context with Promises, if you don’t explicitly catch the error, it might swallow the error. This can lead to your code not working as expected without showing any error messages.

Consider the following code:

new Promise((resolve, reject) => {
    throw new Error('An error occurred');
})
.then(() => console.log('Success!'));

In this example, an error is thrown within the Promise, but there’s no .catch() block to handle it. As a result, the error goes unnoticed, and the program continues to run as if nothing happened. This can make troubleshooting difficult because there’s no clear indicator of what went wrong.

One way to reduce this issue is to always include a .catch() block at the end of your Promise chains, which will catch any errors that occur in any of the preceding .then() blocks.

new Promise((resolve, reject) => {
    throw new Error('An error occurred');
})
.then(() => console.log('Success!'))
.catch(error => console.error('An error occurred:', error));

Now, if an error is thrown, the Promise will be rejected, and the error will be passed to the .catch() block and logged to the console, making it much easier to identify and fix the problem.

An important thing to note is that not all “errors” cause a Promise to reject. If you’re working with a function that returns a Promise, and that function encounters a problem but doesn’t throw an error or return a rejected Promise, the Promise will still resolve successfully, and the .catch() block won’t be triggered. It’s important to ensure that your Promises reject when something goes wrong, so you can catch and handle errors..

Let’s see an example

function fetchData() {
    return new Promise((resolve) => {
        // Simulating data fetching
        const data = null;

        if (data === null) {
            console.log('Data is null');
        }

        resolve(data);
    });
}

fetchData()
    .then(data => console.log('Data:', data))
    .catch(error => console.error('An error occurred:', error));

In this example, the fetchData function simulates an asynchronous data fetching operation. However, let’s assume that due to some issue (like a network error), data is null. We’re logging this situation to the console, but we’re not throwing an error or rejecting the Promise.

So, what happens when we run this code?

The function fetchData is called, and it returns a Promise. The Promise resolves successfully because we’re not doing anything to reject it when data is null. As a result, the .then() block is executed, and ‘Data: null‘ is logged to the console.

Despite the fact that our data fetching operation didn’t work as expected (because data is null), the Promise didn’t reject, and the .catch() block wasn’t triggered.

To handle this situation correctly, we should reject the Promise when an “error” occurs (like data being null), o handle this situation correctly, so we can catch and handle this error. Here’s the corrected code:

function fetchData() {
    return new Promise((resolve, reject) => {
        // Simulating data fetching
        const data = null;

        if (data === null) {
            reject('Data is null');
        } else {
            resolve(data);
        }
    });
}

fetchData()
    .then(data => console.log('Data:', data))
    .catch(error => console.error('An error occurred:', error));

Now, when data is null, the Promise rejects, and the .catch() block is triggered, logging ‘An error occurred: Data is null‘ to the console. This provides a clear indication that something went wrong, making it easier to debug and fix the issue.

Chain Complexity

One of the biggest advantages of Promises is their ability to “chain”. Chaining is a way of connecting several Promise-based operations, where each step is dependent on the successful resolution of the previous step. It allows us to perform complex, sequential asynchronous operations in a more readable and manageable way.

However, as the chain grows in length, it can become more difficult to manage and understand the code. This is because each subsequent operation in the chain depends on the successful completion of the previous one, making it more challenging to track the flow of data and error handling through the chain.

Consider the following example:

fetchData()
.then(data => return process(data))
.then(processedData => return save(processedData))
.then(savedData => console.log(savedData))
.catch(error => console.error(error));

In this Promise chain, we are performing three asynchronous operations sequentially: fetching data, processing the data, and saving the processed data. The output of each step is passed as input to the next step in the chain.

While this chain is fairly straightforward, as chains become more complex—perhaps with conditional logic, nested Promises, or additional steps—it can be more difficult to maintain and debug.

One issue is that it might not be immediately clear which .then() clause an error pertains to if one is caught. This can make debugging more difficult, especially in more complex Promise chains.

Additionally, with each added .then(), there is an increase in indentation level. While not as extreme as callback hell, this can still lead to less readable code.

To manage chain complexity, consider the following tips:

  1. Keep your chains short: Break down long Promise chains into smaller functions that each perform a specific task. This makes the code more modular and easier to understand.
  2. Use async/await: Introduced in ES8, You can use async and await to write asynchronous code that looks and behaves more like synchronous code, making it easier to understand
  3. Error Handling: Always include a .catch() at the end of your Promise chain, and consider adding .catch() clauses after each .then() in a complex chain to handle errors at each step of the chain.

Let’s go through each tip with a detailed explanation and code examples.

Keep your chains short

By keeping your Promise chains short and breaking them into smaller functions, you can increase code readability and maintainability. Here’s an example of how you could refactor a long chain into smaller parts:

// Before
fetchData()
.then(data => process(data))
.then(processedData => save(processedData))
.then(savedData => console.log(savedData))
.catch(error => console.error(error));

// After
function fetchDataAndProcess() {
    return fetchData()
        .then(data => process(data));
}

function saveAndLogData(data) {
    return save(data)
        .then(savedData => console.log(savedData));
}

fetchDataAndProcess()
.then(data => saveAndLogData(data))
.catch(error => console.error(error));

In the refactored code, we’ve split the original Promise chain into two separate functions. Each function has its specific responsibility, making it easier to understand and manage.

Use async/await

The async/await syntax allows us to write asynchronous code that looks like synchronous code. It can make Promise chains easier to read and understand.

Let’s refactor the previous example with async/await:

async function fetchDataAndProcessAndSave() {
    try {
        const data = await fetchData();
        const processedData = await process(data);
        const savedData = await save(processedData);
        console.log(savedData);
    } catch (error) {
        console.error(error);
    }
}

fetchDataAndProcessAndSave();

In the refactored code, we’re using the async keyword to indicate that a function is asynchronous, and the await keyword to wait for a Promise to resolve. This results in code that’s easier to follow because it reads like synchronous code.

Remember, the goal of using Promises is to write cleaner, more maintainable asynchronous code. If your Promise chains are becoming unwieldy, it may be a sign that you need to rethink your approach.

Error Handling

Effective error handling is crucial for managing Promise chains. Adding a .catch() block after each .then() allows you to handle errors at each step in the chain.

fetchData()
.then(data => {
    return process(data);
}).catch(error => {
    console.error('Error processing data:', error);
    throw error; // To propagate the error
})
.then(processedData => {
    return save(processedData);
}).catch(error => {
    console.error('Error saving data:', error);
    throw error; // To propagate the error
})
.then(savedData => {
    console.log(savedData);
}).catch(error => {
    console.error('General error:', error);
});

In this example, we have a .catch() clause after each .then(). This allows us to handle and log errors at each step in the chain. Note the use of throw error in the .catch() clauses to ensure that the error is propagated down the chain.

Nested Promises

Promises excellently avoid callback hell and make your code more readable, but nesting Promises can reintroduce some of the problems they aim to solve. Similar to nested callbacks, nested Promises can lead to increased complexity and more difficult-to-read code.

In a Promise chain, you should generally return Promises from your .then() callbacks, allowing them to be chained off of. However, if you create a new Promise inside a .then() callback and do not return it, this creates a nested Promise.

Here’s an example of what a nested Promise might look like:

fetchData()
.then(data => {
    process(data)
    .then(processedData => {
        save(processedData)
        .then(savedData => {
            console.log(savedData);
        });
    });
})
.catch(error => console.error(error));

In this code, we are creating a new Promise inside each .then() callback, but not returning it. This creates a “Promise pyramid”, similar to “callback hell”. It increases the indentation level of the code, making it harder to read and understand. Error handling can also become more tricky with nested Promises because an inner Promise’s errors are not caught by an outer Promise’s .catch() clause.

Instead of nesting Promises like this, it’s generally better to return them from your .then() callbacks to form a flat chain:

fetchData()
.then(data => {
    return process(data);
})
.then(processedData => {
    return save(processedData);
})
.then(savedData => {
    console.log(savedData);
})
.catch(error => console.error(error));

In this refactored code, we are returning the Promises from the .then() callbacks, forming a flat Promise chain. This makes the code easier to read and manage, and it allows errors at any step in the chain to be caught by the .catch() clause.

Remember, one of the main goals of using Promises is to write cleaner, more manageable asynchronous code. If you find yourself nesting Promises, it’s usually a sign that you need to refactor your code to take full advantage of Promise chaining.

No Cancelation

Promises in JavaScript have a significant drawback: once you create them, you can’t cancel them. A Promise represents a value that might not be available now but will resolve in the future. Once you initiate a Promise, it remains in one of three states: pending, resolved (fulfilled), or rejected.

Promises in JavaScript have a significant drawback: once you create them, you can’t cancel them. A Promise represents a value that might not be available now but will resolve in the future. Once you initiate a Promise, it remains in one of three states: pending, resolved (fulfilled), or rejected.

This lack of cancelation support can create challenges in certain scenarios. For example, let’s consider a scenario where you’re making a data fetching request when a user clicks a button. If the user clicks the button again before completing the first request, you might consider canceling the first request. However, since you can’t cancel Promises, you can only ignore the result of the first Promise when it resolves. Nonetheless, it will still execute all its operations

let latestRequest = null;

function fetchData() {
    latestRequest = new Promise((resolve, reject) => {
        // Simulate an asynchronous data fetch
        setTimeout(() => resolve('Data fetched'), 2000);
    });
    return latestRequest;
}

function handleClick() {
    const currentRequest = fetchData();
    currentRequest
        .then(data => {
            if (currentRequest === latestRequest) {
                console.log(data);
            }
        });
}

In this example, every time the handleClick function is called, it triggers a new data fetch. It compares the Promise returned by fetchData to the latestRequest variable to ensure it only logs the data from the most recent request. However, all previous data fetches still resolve, even though their results are ignored.

It’s important to note that while you can’t cancel a Promise once it’s been created, there are other APIs and libraries that offer cancelable asynchronous operations, such as the Fetch API‘s AbortController interface or libraries like Bluebird.

Keep in mind, however, that these workarounds do not cancel the Promise itself; they just provide a way to cancel the underlying operation that the Promise represents. The Promise will still be pending until it’s either resolved or rejected.

No Progress Update

Promises in JavaScript are designed to handle a single asynchronous operation. They are not capable of reporting progress updates for ongoing operations. A Promise represents an operation that is either completed (fulfilled), failed (rejected), or pending but not the ongoing state.

For instance, let’s assume we have a function that downloads a large file. With Promises, we can’t receive continuous updates about how much of the file has been downloaded. We can only know when the download is completed or if it fails. Here’s an example of what this might look like:

downloadLargeFile()
.then(file => {
    console.log('File downloaded:', file);
})
.catch(error => {
    console.error('An error occurred:', error);
});

In this example, the downloadLargeFile function returns a Promise that resolves when the file is downloaded. However, there’s no way to get progress updates during the download. You’ll only get a single notification when the Promise is either resolved or rejected.

This lack of progress reporting can be a drawback in situations where you want to keep the user informed about the status of a long-running operation.

For operations that require progress updates, other APIs may be more appropriate. For example, the Fetch API provides a way to read a response body as a stream, allowing you to track the progress of the response. Similarly, libraries like Axios provide progress updates for download and upload operations.

Here’s an example of how you could use this with fetch API:

fetch('https://example.com/large-file')
.then(response => {
    const totalBytes = +response.headers.get('Content-Length');
    const reader = response.body.getReader();
    let bytesReceived = 0;

    return new ReadableStream({
        start(controller) {
            function read() {
                reader.read().then(({done, value}) => {
                    if (done) {
                        controller.close();
                        return;
                    }
                    bytesReceived += value.length;
                    console.log(`Download progress: ${((bytesReceived / totalBytes) * 100).toFixed(2)}%`);

                    controller.enqueue(value);
                    read();
                });
            }
            read();
        }
    });
})
.then(stream => {
    // Handle the data from the stream
})
.catch(error => {
    console.error('An error occurred:', error);
})

In this code, we’re creating a custom ReadableStream and using the Fetch API’s stream reader to read chunks of the response body. For each chunk, we log a message indicating the download progress as a percentage of the total file size.

Also, let’s see how Axios provides a more straightforward way to monitor download progress:

const axios = require('axios');

axios({
    method: 'get',
    url: 'https://example.com/large-file',
    responseType: 'stream'
})
.then(response => {
    const totalBytes = +response.headers['content-length'];
    let bytesReceived = 0;

    response.data.on('data', chunk => {
        bytesReceived += chunk.length;
        console.log(`Download progress: ${((bytesReceived / totalBytes) * 100).toFixed(2)}%`);
    });

    response.data.on('end', () => {
        // Handle the completed download
    });
})
.catch(error => {
    console.error('An error occurred:', error);
});

In this Axios example, we’re using the stream response type to handle the response body as a Node.js stream. This allows us to listen for data events, which are emitted each time a chunk of data is received. As in the Fetch example, we log a message indicating the download progress for each chunk.

State Visibility

Promises in JavaScript don’t provide an in-built way to check their current state—whether they’re pending, fulfilled, or rejected. Once a Promise is created, it starts in the pending state, and at some point, it will settle to either fulfilled (if resolved) or rejected (if an error is thrown). However, there’s no direct way to inspect the state of a Promise from outside.

This lack of state visibility can pose a challenge when debugging code that involves Promises. Consider the following example:

const promise = fetchData();

// Some other code...

// Now we want to know the state of 'promise'
// But there's no built-in way to do this!

In this example, fetchData() returns a Promise, but after this Promise is created, there’s no way to check whether it’s still pending directly, or if it has been fulfilled or rejected.

While JavaScript itself doesn’t offer a direct solution, you can track the state of a Promise manually by setting up your own flags. Consider this workaround:

let isPending = true;
let isRejected = false;
let isFulfilled = false;

const promise = fetchData()
.then(() => {
    isFulfilled = true;
})
.catch(() => {
    isRejected = true;
})
.finally(() => {
    isPending = false;
});

// Now we can check the state of 'promise'
console.log({ isPending, isFulfilled, isRejected });  // { isPending: true, isFulfilled: false, isRejected: false }

In the second example, we manually set up flags for the pending, fulfilled, and rejected states. When the Promise resolves, we set isFulfilled to true. If it rejects, we set isRejected to true. In either case, we set isPending to false in the .finally() block.

This approach provides a way to check the state of a Promise but also involves writing additional code, which can add complexity to your application.

In general, it’s best to structure your code so that you don’t need to inspect the state of a Promise. Instead, handle all necessary logic inside .then(), .catch(), and .finally() as appropriate. In rare cases where you need to track Promise states, consider the manual flag-setting approach with caution.

Further Reading

Conclusion

In conclusion, while Promises in JavaScript provide a powerful and robust way to manage asynchronous operations, they do come with their own set of challenges. From the absence of built-in error handling, issues with chain complexity and nested promises, to the lack of cancellation support, progress updates, and state visibility, these pitfalls may introduce additional complexities in your code. 

However, knowing these potential drawbacks, you can navigate the waters of JavaScript Promises with more confidence. Understanding these complications helps you write cleaner, more manageable code, and makes you better prepared to choose the right tools and patterns for handling asynchronous operations. Happy coding!

References

MDN Web Docs: Promise

JavaScript.info: Promises

ECMAScript 6 Promises (ES6) – JavaScript

Promise – JavaScript | MDN

Leave a Reply

Your email address will not be published. Required fields are marked *