Promises in JavaScript

Promise Structure and States

Introduction to Promises

Welcome to our exploration of JavaScript Promises! Promises represent a significant improvement in handling asynchronous operations compared to the callback approach we studied earlier. They provide a more structured, readable way to deal with asynchronous code and address many of the challenges of "callback hell."

In essence, a Promise is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value. Think of a Promise as a receipt you get when ordering food at a restaurant: it's not the food itself, but a guarantee that you'll either receive your meal or get an explanation of why it can't be prepared.

graph LR A[Request
Asynchronous
Operation] --> B[Promise
Object] B -->|Success| C[Fulfilled
with value] B -->|Failure| D[Rejected
with reason] C --> E[Then handlers
process value] D --> F[Catch handlers
process error]

Why Promises?

Before diving into how Promises work, let's understand why they were added to JavaScript:

Promises were standardized in ES6 (ECMAScript 2015) and are now supported in all modern browsers and Node.js.

Promise States and Lifecycle

A Promise can be in one of three states:

The Three Promise States

Promise Lifecycle

Understanding the lifecycle of a Promise is crucial:

stateDiagram-v2 [*] --> Pending: Promise created Pending --> Fulfilled: Operation succeeds Pending --> Rejected: Operation fails Fulfilled --> [*]: Promise settled Rejected --> [*]: Promise settled note right of Pending Initial state when Promise is created - No result value yet - No error reason yet end note note right of Fulfilled Terminal success state - Has result value - Triggers .then() handlers - Can never change state end note note right of Rejected Terminal failure state - Has error reason - Triggers .catch() handlers - Can never change state end note

Key characteristics of the Promise lifecycle:

This state management is handled internally by the JavaScript engine - you can't directly manipulate a Promise's state from outside. Instead, you work with Promises through their API methods.

Creating Promises

Let's examine how to create Promises in JavaScript.

The Promise Constructor

The most basic way to create a Promise is using the Promise constructor, which takes a function (the "executor") as an argument:

const myPromise = new Promise(function(resolve, reject) {
    // Asynchronous operation goes here
    
    if (/* operation successful */) {
        resolve(value); // Fulfill the promise with a value
    } else {
        reject(reason); // Reject the promise with a reason
    }
});

The executor function takes two arguments: resolve and reject. These are functions provided by the Promise implementation that you call to indicate success or failure:

Example: Creating a Simple Promise

// A Promise that simulates an API call with a 50% chance of success
const simulateApiCall = new Promise((resolve, reject) => {
    // Simulate network delay
    setTimeout(() => {
        // Randomly succeed or fail
        const randomSuccess = Math.random() > 0.5;
        
        if (randomSuccess) {
            resolve({ 
                status: 200, 
                data: { message: "Operation successful" } 
            });
        } else {
            reject(new Error("Network error"));
        }
    }, 2000);
});

// Using the Promise
simulateApiCall
    .then(response => {
        console.log("Success:", response.data.message);
    })
    .catch(error => {
        console.error("Failed:", error.message);
    });

This example demonstrates a Promise that simulates an API call with a random outcome. After 2 seconds, it will either resolve with a success response or reject with an error.

Creating Resolved or Rejected Promises

Sometimes you need to create a Promise that's already fulfilled or rejected. JavaScript provides static methods for this:

// Create an already-fulfilled Promise
const fulfilledPromise = Promise.resolve("Success value");

// Create an already-rejected Promise
const rejectedPromise = Promise.reject(new Error("Failure reason"));

// Using pre-resolved Promises
fulfilledPromise.then(value => {
    console.log("Fulfilled:", value); // Logs: "Fulfilled: Success value"
});

rejectedPromise.catch(reason => {
    console.log("Rejected:", reason.message); // Logs: "Rejected: Failure reason"
});

These methods are useful for creating Promises from known values, error handling, and testing Promise-based code.

Promisifying Callback-Based APIs

A common practical use of the Promise constructor is to convert callback-based APIs to Promise-based ones:

// Traditional callback-based function
function readFileCallback(path, callback) {
    // Simulate reading a file asynchronously
    setTimeout(() => {
        if (path.endsWith('.txt')) {
            callback(null, `Content of ${path}`);
        } else {
            callback(new Error('Only .txt files are supported'));
        }
    }, 1000);
}

// Converting to a Promise-based function
function readFilePromise(path) {
    // Return a new Promise
    return new Promise((resolve, reject) => {
        // Call the original callback-based function
        readFileCallback(path, (error, content) => {
            if (error) {
                reject(error); // If there's an error, reject the Promise
            } else {
                resolve(content); // If successful, resolve the Promise
            }
        });
    });
}

// Using the Promise-based function
readFilePromise('document.txt')
    .then(content => {
        console.log('File content:', content);
    })
    .catch(error => {
        console.error('Error reading file:', error.message);
    });

readFilePromise('image.jpg')
    .then(content => {
        console.log('File content:', content);
    })
    .catch(error => {
        console.error('Error reading file:', error.message); // This will execute
    });

This technique, often called "promisification," is a powerful way to upgrade legacy code to use modern Promise-based patterns.

Promise Consumption: then, catch, and finally

Once you have a Promise, you need to consume it by attaching handlers that will execute when the Promise settles. The Promise API provides three main methods for this: .then(), .catch(), and .finally().

The .then() Method

The .then() method adds a fulfillment and/or rejection handler to a Promise and returns a new Promise:

promise.then(
    // onFulfilled handler
    function(value) {
        // Called when the Promise is fulfilled
        console.log('Success:', value);
        return processedValue; // Becomes the value of the returned Promise
    },
    // onRejected handler (optional)
    function(reason) {
        // Called when the Promise is rejected
        console.error('Error:', reason);
        throw new Error('Processed error'); // Becomes the reason of the returned Promise
    }
);

Key points about .then():

The .catch() Method

The .catch() method adds only a rejection handler and returns a new Promise:

promise.catch(function(reason) {
    // Called when the Promise is rejected
    console.error('Error:', reason);
    
    // Can return a value to recover from the error
    return fallbackValue;
});

The .catch() method is essentially a shorthand for .then(null, onRejected). It's used for error handling in Promise chains.

The .finally() Method

The .finally() method adds a handler that executes regardless of whether the Promise fulfills or rejects:

promise.finally(function() {
    // Called when the Promise settles, regardless of outcome
    console.log('Promise settled');
    
    // Clean up code goes here (e.g., hiding loading indicators, etc.)
    hideLoadingSpinner();
});

The .finally() method is useful for cleanup operations that should happen regardless of success or failure, similar to a try/catch/finally block in synchronous code.

Complete Example

function fetchUserData(userId) {
    return new Promise((resolve, reject) => {
        // Simulate API call
        setTimeout(() => {
            if (userId > 0) {
                resolve({
                    id: userId,
                    name: 'User ' + userId,
                    email: `user${userId}@example.com`
                });
            } else {
                reject(new Error('Invalid user ID'));
            }
        }, 1000);
    });
}

// Start with a loading state
const loadingIndicator = document.getElementById('loading');
loadingIndicator.style.display = 'block';

fetchUserData(42)
    .then(user => {
        console.log('User data:', user);
        updateUserProfile(user);
        return user; // Pass the user to the next then
    })
    .then(user => {
        return fetchUserPosts(user.id); // Return a new Promise
    })
    .then(posts => {
        console.log('User posts:', posts);
        displayUserPosts(posts);
    })
    .catch(error => {
        // This handles errors from any previous step
        console.error('Error in user data flow:', error);
        showErrorMessage(error.message);
    })
    .finally(() => {
        // This runs regardless of success or failure
        loadingIndicator.style.display = 'none';
        console.log('User data fetching operation complete');
    });

This example demonstrates a typical Promise workflow with loading state management and error handling.

Real-World Scenario: User Authentication Flow

Here's how Promises can model a typical user authentication flow:

function authenticateUser(credentials) {
    // Show loading indicator
    showLoadingState("Authenticating...");
    
    // Start the authentication flow
    validateCredentials(credentials)
        .then(validatedCredentials => {
            updateLoadingState("Contacting server...");
            return sendLoginRequest(validatedCredentials);
        })
        .then(authResponse => {
            updateLoadingState("Setting up your account...");
            return setupUserSession(authResponse.token);
        })
        .then(userSession => {
            updateLoadingState("Loading your dashboard...");
            return loadUserDashboard(userSession.userId);
        })
        .then(dashboardData => {
            // Success path - redirect to dashboard
            hideLoadingState();
            displayDashboard(dashboardData);
        })
        .catch(error => {
            // Handle different types of errors differently
            hideLoadingState();
            
            if (error.name === 'ValidationError') {
                showFormError(error.message);
            } else if (error.name === 'AuthenticationError') {
                showLoginFailedMessage(error.message);
            } else if (error.name === 'NetworkError') {
                showOfflineMessage();
            } else {
                showGenericError("Something went wrong. Please try again.");
                console.error(error);
            }
        });

This example shows how Promises enable a readable, step-by-step authentication process with centralized error handling and loading state management.

Promise Behavior Details

Let's explore some important nuances in how Promises behave.

Asynchronous Execution

Promise handlers are always executed asynchronously, even if the Promise is already settled:

console.log("Start");

// Create an already-resolved Promise
const immediatePromise = Promise.resolve("Immediate value");

immediatePromise.then(value => {
    console.log("Promise value:", value);
});

console.log("End");

// Output:
// "Start"
// "End"
// "Promise value: Immediate value"

Notice that even though the Promise is already resolved when we attach the .then() handler, the handler executes after the synchronous code completes. This is because Promise handlers are scheduled as microtasks.

Promise Immutability

Once a Promise settles, its state and value/reason can never change:

const promise = new Promise((resolve, reject) => {
    resolve("First value");
    
    // These have no effect - Promise already settled
    resolve("Second value");
    reject(new Error("Attempted rejection after resolution"));
});

promise.then(value => {
    console.log("Value:", value); // Always logs: "Value: First value"
});

This immutability is an important guarantee that Promises provide. Once a Promise completes, it will always have the same outcome regardless of when you attach handlers to it.

Error Propagation in Promise Chains

Errors propagate down a Promise chain until handled by a rejection handler:

Promise.resolve("Starting value")
    .then(value => {
        console.log("First step:", value);
        throw new Error("Something went wrong");
    })
    .then(value => {
        // This is skipped due to the error
        console.log("Second step:", value);
        return "Updated value";
    })
    .then(value => {
        // This is also skipped
        console.log("Third step:", value);
        return "Final value";
    })
    .catch(error => {
        // This catches the error from the first step
        console.error("Caught error:", error.message);
        return "Recovery value";
    })
    .then(value => {
        // This runs with the recovery value
        console.log("After recovery:", value);
    });

// Output:
// "First step: Starting value"
// "Caught error: Something went wrong"
// "After recovery: Recovery value"

This behavior makes error handling much cleaner in Promise chains compared to nested callbacks, as you can place a single .catch() handler at the end of a chain to handle errors from any step.

Common Mistakes and Pitfalls

When working with Promises, be aware of these common issues:

Losing the Promise Chain

// Incorrect - breaking the chain
function fetchAndProcessData() {
    fetch('/api/data')
        .then(response => response.json());  // No return statement!
    
    // This returns undefined, not a Promise!
}

// Correct - maintaining the chain
function fetchAndProcessData() {
    return fetch('/api/data')
        .then(response => response.json());
}

// Usage
fetchAndProcessData()
    .then(data => {
        console.log("Data:", data);
    })
    .catch(error => {
        console.error("Error:", error);
    });

Forgetting to Handle Rejection

// Incorrect - no error handling
fetch('/api/data')
    .then(response => response.json())
    .then(data => {
        console.log("Data:", data);
    });
// If fetch fails, an unhandled promise rejection occurs

// Correct - with error handling
fetch('/api/data')
    .then(response => response.json())
    .then(data => {
        console.log("Data:", data);
    })
    .catch(error => {
        console.error("Error:", error);
    });

Excessive Nesting Instead of Chaining

// Incorrect - nesting creates "Promise hell"
fetch('/api/user')
    .then(response => response.json())
    .then(user => {
        fetch(`/api/user/${user.id}/posts`)
            .then(response => response.json())
            .then(posts => {
                fetch(`/api/user/${user.id}/followers`)
                    .then(response => response.json())
                    .then(followers => {
                        console.log(user, posts, followers);
                    });
            });
    });

// Correct - flat chaining with proper returns
fetch('/api/user')
    .then(response => response.json())
    .then(user => {
        // Store user data and fetch posts
        userData = user;
        return fetch(`/api/user/${user.id}/posts`);
    })
    .then(response => response.json())
    .then(posts => {
        // Store posts and fetch followers
        userPosts = posts;
        return fetch(`/api/user/${userData.id}/followers`);
    })
    .then(response => response.json())
    .then(followers => {
        // Now we have all three: userData, userPosts, and followers
        console.log(userData, userPosts, followers);
    })
    .catch(error => {
        console.error("Error in chain:", error);
    });

Understanding these pitfalls will help you write more robust Promise-based code.

Practical Exercise: Building a Promise-Based Timer

Now let's apply what we've learned by building a practical utility: a Promise-based timer with additional features.

Exercise: Enhanced Promise Timer

Create a Promise-based timer utility with these features:

Implementation

// Basic delay function
function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// Cancellable timer
function cancellableDelay(ms) {
    // Create an object to hold the cancel function
    const cancelObj = {};
    
    // Create the promise
    const promise = new Promise((resolve, reject) => {
        // Store the timeout ID so it can be cleared
        const timeoutId = setTimeout(resolve, ms);
        
        // Define the cancel function
        cancelObj.cancel = function() {
            clearTimeout(timeoutId);
            reject(new Error('Timer was cancelled'));
        };
    });
    
    // Return both the promise and the cancel function
    return {
        promise: promise,
        cancel: cancelObj.cancel
    };
}

// Progress tracking timer
function delayWithProgress(ms, updateInterval = 100) {
    return new Promise((resolve) => {
        const startTime = Date.now();
        const endTime = startTime + ms;
        
        // Track progress
        function updateProgress() {
            const currentTime = Date.now();
            const elapsed = currentTime - startTime;
            const remaining = ms - elapsed;
            
            if (currentTime >= endTime) {
                // We're done
                resolve({
                    completed: true,
                    elapsed: ms,
                    remaining: 0,
                    progress: 100
                });
            } else {
                // Calculate progress percentage
                const progress = (elapsed / ms) * 100;
                
                // Update progress handlers if registered
                if (typeof updateProgress.onProgress === 'function') {
                    updateProgress.onProgress({
                        completed: false,
                        elapsed: elapsed,
                        remaining: remaining,
                        progress: progress
                    });
                }
                
                // Schedule next update
                setTimeout(updateProgress, updateInterval);
            }
        }
        
        // Start the first update
        setTimeout(updateProgress, 0);
        
        // Return the function to attach progress handlers
        return promise;
    });
}

// Add progress handler to a delayWithProgress promise
function trackProgress(delayPromise, onProgress) {
    delayPromise.updateProgress.onProgress = onProgress;
    return delayPromise;
}

// Timeout wrapper for any promise
function withTimeout(promise, ms) {
    // Create a timeout promise that rejects after ms milliseconds
    const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error(`Operation timed out after ${ms}ms`));
        }, ms);
    });
    
    // Race the original promise against the timeout
    return Promise.race([promise, timeoutPromise]);
}

// Example usage
const loadingElement = document.getElementById('loading-status');

// Use the enhanced timer utilities
function runTimerDemo() {
    console.log("Starting basic delay...");
    
    delay(2000)
        .then(() => {
            console.log("Basic delay completed after 2 seconds");
            return runCancellableDemo();
        })
        .catch(error => {
            console.error("Error:", error);
        });
}

function runCancellableDemo() {
    console.log("Starting cancellable delay...");
    
    const { promise, cancel } = cancellableDelay(5000);
    
    // Set up cancel button
    const cancelButton = document.getElementById('cancel-button');
    cancelButton.addEventListener('click', cancel);
    cancelButton.disabled = false;
    
    return promise
        .then(() => {
            console.log("Cancellable delay completed!");
            cancelButton.disabled = true;
            return runProgressDemo();
        })
        .catch(error => {
            console.log("Cancellable delay result:", error.message);
            cancelButton.disabled = true;
            return runProgressDemo();
        });
}

function runProgressDemo() {
    console.log("Starting progress tracking delay...");
    
    const progressBar = document.getElementById('progress-bar');
    progressBar.style.width = '0%';
    
    const delayPromise = delayWithProgress(5000);
    
    // Track progress
    trackProgress(delayPromise, (progress) => {
        console.log(`Progress: ${progress.progress.toFixed(1)}%`);
        progressBar.style.width = `${progress.progress}%`;
        loadingElement.textContent = `Elapsed: ${(progress.elapsed / 1000).toFixed(1)}s, Remaining: ${(progress.remaining / 1000).toFixed(1)}s`;
    });
    
    return delayPromise.then((result) => {
        console.log("Progress tracking completed:", result);
        return runTimeoutDemo();
    });
}

function runTimeoutDemo() {
    console.log("Starting timeout demo...");
    
    const slowOperation = new Promise(resolve => {
        // This operation takes 4 seconds
        setTimeout(() => resolve("Slow operation completed"), 4000);
    });
    
    // Timeout after 2 seconds
    return withTimeout(slowOperation, 2000)
        .then(result => {
            console.log("Result:", result); // This won't execute
        })
        .catch(error => {
            console.log("Error:", error.message); // "Operation timed out after 2000ms"
            console.log("Demo sequence completed!");
        });
}

// Start the demo sequence
runTimerDemo();

HTML Setup for the Demo

<div class="timer-demo">
    <h3>Promise Timer Demo</h3>
    
    <div class="demo-section">
        <h4>Cancellable Timer</h4>
        <button id="cancel-button" disabled>Cancel Timer</button>
    </div>
    
    <div class="demo-section">
        <h4>Progress Tracking</h4>
        <div class="progress-container">
            <div id="progress-bar" class="progress-bar"></div>
        </div>
        <div id="loading-status">Waiting to start...</div>
    </div>
    
    <div class="demo-section">
        <h4>Console Output</h4>
        <div id="console-output" class="console"></div>
    </div>
</div>

<style>
.timer-demo {
    padding: 20px;
    background-color: #f5f5f5;
    border-radius: 8px;
}

.demo-section {
    margin-bottom: 20px;
}

.progress-container {
    width: 100%;
    height: 20px;
    background-color: #ddd;
    border-radius: 10px;
    margin-bottom: 10px;
    overflow: hidden;
}

.progress-bar {
    height: 100%;
    background-color: #4CAF50;
    width: 0%;
    transition: width 0.1s;
}

.console {
    background-color: #333;
    color: #fff;
    font-family: monospace;
    padding: 10px;
    border-radius: 5px;
    max-height: 200px;
    overflow-y: auto;
}
</style>

This exercise demonstrates several key concepts about Promises:

Summary and Next Steps

In this lecture, we've explored:

Promises represent a significant improvement over traditional callbacks for handling asynchronous operations. They provide better structure, error handling, and composition capabilities that make your code more readable and maintainable.

In our next lecture, we'll explore Promise chaining and composition, where we'll learn advanced techniques for working with multiple Promises and creating more complex asynchronous workflows.

Additional Practice Exercises

  1. Promisify setTimeout: Create a function that wraps setTimeout in a Promise, allowing you to use it with async/await.
  2. Promisify XMLHttpRequest: Create a Promise-based wrapper for the XMLHttpRequest API.
  3. Promise State Observer: Build a utility that can log the state changes of a Promise as it progresses.
  4. Retry Mechanism: Create a function that attempts an operation multiple times until it succeeds or reaches a maximum number of retries.

Additional Resources