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.
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:
- Improved Readability: Promises chain operations with methods like .then() and .catch(), creating a more linear flow than nested callbacks
- Better Error Handling: Consolidated error handling with .catch() instead of checking errors at each callback level
- Composition: Easier to compose complex asynchronous operations, including parallel execution
- Guarantees: Promises provide guarantees about asynchronous code behavior that callbacks don't
- Foundation for async/await: Promises form the basis for the even more readable async/await syntax
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
-
Pending: The initial state. The Promise is neither fulfilled nor rejected.
- The asynchronous operation hasn't completed yet
- Promise is still "in progress"
-
Fulfilled: The operation completed successfully.
- The Promise has resolved with a value
- This state triggers .then() handlers
- Once fulfilled, a Promise can never change state
-
Rejected: The operation failed.
- The Promise has rejected with a reason (usually an Error object)
- This state triggers .catch() handlers or the second argument of .then()
- Once rejected, a Promise can never change state
Promise Lifecycle
Understanding the lifecycle of a Promise is crucial:
Key characteristics of the Promise lifecycle:
- One-way Transitions: A Promise can only move from pending to either fulfilled or rejected, never between fulfilled and rejected
- Immutability: Once fulfilled or rejected, a Promise is considered "settled" and its state and value/reason can never change
- Asynchronous Resolution: Even if a Promise resolves immediately, the handlers attached to it run asynchronously
- Microtask Queuing: Promise resolution handlers are queued as microtasks, which have priority over regular tasks in the event loop
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:
- resolve(value): Call this when the operation succeeds, with the resulting value
- reject(reason): Call this when the operation fails, with the reason for failure (typically an Error object)
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():
- It takes up to two arguments: handlers for fulfillment and rejection
- Either handler can be omitted (pass null or undefined)
- It always returns a new Promise, enabling chaining
- The return value from a handler becomes the fulfillment value of the returned Promise
- If a handler throws an exception, the returned Promise is rejected with that exception
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:
- Basic delay function that returns a Promise resolving after a specified time
- Cancellable timer that can be aborted before completion
- Progress tracking during the countdown
- Timeout with automatic rejection if a provided operation takes too long
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:
- Creating custom Promise-based utilities
- Combining Promises with other APIs (setTimeout, DOM updates)
- Building cancelable Promises (which are not natively supported)
- Using Promise.race() for implementing timeouts
- Extending Promises with additional functionality like progress tracking
Summary and Next Steps
In this lecture, we've explored:
- The concept and benefits of Promises in JavaScript
- The three Promise states: pending, fulfilled, and rejected
- How to create Promises using the constructor and static methods
- Consuming Promises with .then(), .catch(), and .finally()
- Important details about Promise behavior and common pitfalls
- Practical applications with a Promise-based timer utility
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
- Promisify setTimeout: Create a function that wraps setTimeout in a Promise, allowing you to use it with async/await.
- Promisify XMLHttpRequest: Create a Promise-based wrapper for the XMLHttpRequest API.
- Promise State Observer: Build a utility that can log the state changes of a Promise as it progresses.
- Retry Mechanism: Create a function that attempts an operation multiple times until it succeeds or reaches a maximum number of retries.