I’ve been avoiding Promises for too long. Callbacks worked fine, right? Wrong. After refactoring a callback-heavy codebase to use Promises, I’m never going back.

The Problem with Callbacks

Here’s what our code looked like before:

getUser(userId, function(err, user) {
    if (err) {
        handleError(err);
        return;
    }
    
    getOrders(user.id, function(err, orders) {
        if (err) {
            handleError(err);
            return;
        }
        
        getOrderDetails(orders[0].id, function(err, details) {
            if (err) {
                handleError(err);
                return;
            }
            
            // Finally do something with details
            processDetails(details);
        });
    });
});

The dreaded callback pyramid of doom. Error handling is repetitive, and the logic is hard to follow.

The Promise Way

Same code with Promises:

getUser(userId)
    .then(user => getOrders(user.id))
    .then(orders => getOrderDetails(orders[0].id))
    .then(details => processDetails(details))
    .catch(err => handleError(err));

Much cleaner. The logic flows top to bottom, and error handling is centralized.

How Promises Work

A Promise represents a value that might not be available yet. It can be in one of three states:

  • Pending: Initial state, neither fulfilled nor rejected
  • Fulfilled: Operation completed successfully
  • Rejected: Operation failed
const promise = new Promise((resolve, reject) => {
    // Do async work
    if (success) {
        resolve(result);
    } else {
        reject(error);
    }
});

promise
    .then(result => {
        // Handle success
    })
    .catch(error => {
        // Handle error
    });

Converting Callbacks to Promises

Most of our code uses Node-style callbacks. Here’s how I wrapped them:

function promisify(fn) {
    return function(...args) {
        return new Promise((resolve, reject) => {
            fn(...args, (err, result) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(result);
                }
            });
        });
    };
}

// Usage
const getUserPromise = promisify(getUser);
getUserPromise(userId).then(user => console.log(user));

Node 8 will have util.promisify() built-in, but for now, I’m using this helper.

Promise Chaining

The power of Promises is in chaining:

fetch('/api/user')
    .then(response => response.json())
    .then(user => {
        console.log(user.name);
        return fetch(`/api/orders/${user.id}`);
    })
    .then(response => response.json())
    .then(orders => {
        console.log(orders);
    })
    .catch(error => {
        console.error('Something went wrong:', error);
    });

Each .then() returns a new Promise, allowing you to chain operations.

Promise.all for Parallel Operations

When you need to wait for multiple Promises:

Promise.all([
    fetch('/api/users'),
    fetch('/api/products'),
    fetch('/api/orders')
])
.then(([users, products, orders]) => {
    // All three requests completed
    console.log(users, products, orders);
})
.catch(error => {
    // Any request failed
    console.error(error);
});

This runs all three requests in parallel and waits for all to complete. Much faster than sequential requests.

Common Mistakes

1. Forgetting to Return

// Wrong - breaks the chain
promise
    .then(result => {
        doSomething(result);  // Forgot to return!
    })
    .then(result => {
        // result is undefined here
    });

// Right
promise
    .then(result => {
        return doSomething(result);
    })
    .then(result => {
        // result is the return value of doSomething
    });

2. Nesting Promises

// Wrong - defeats the purpose
promise.then(result => {
    return anotherPromise.then(result2 => {
        return yetAnother.then(result3 => {
            // Back to callback hell!
        });
    });
});

// Right - chain them
promise
    .then(result => anotherPromise)
    .then(result2 => yetAnother)
    .then(result3 => {
        // Clean and flat
    });

3. Not Handling Errors

// Wrong - unhandled rejection
promise.then(result => {
    // What if this fails?
});

// Right - always catch
promise
    .then(result => {
        // Handle success
    })
    .catch(error => {
        // Handle error
    });

Browser Support

Promises are part of ES6, but not all browsers support them yet. We’re using a polyfill:

<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=Promise"></script>

Or if you’re using Babel, it includes a Promise polyfill.

What’s Next: Async/Await

ES2017 will have async/await, which makes Promises even cleaner:

async function getOrderDetails(userId) {
    try {
        const user = await getUser(userId);
        const orders = await getOrders(user.id);
        const details = await getOrderDetails(orders[0].id);
        return details;
    } catch (error) {
        handleError(error);
    }
}

Looks like synchronous code but is actually asynchronous. Can’t wait for this to land.

Should You Use Promises?

Yes. Absolutely. The benefits are huge:

  • Cleaner code
  • Better error handling
  • Easier to reason about
  • Composable

We’ve converted about 80% of our codebase to Promises, and it’s been worth every minute spent refactoring.

If you’re still using callbacks everywhere, give Promises a try. Your future self will thank you.

Resources

What’s your experience with Promises? Any gotchas I missed?