Introduction to Promise Chaining
Welcome to our exploration of Promise chaining and composition! In this lecture, we'll dive deeper into one of the most powerful features of Promises: the ability to chain operations together to create clean, readable asynchronous workflows.
Promise chaining is a technique that takes advantage of the fact that every Promise method (.then(), .catch(), .finally()) returns a new Promise. This allows us to sequence asynchronous operations in a way that's much cleaner than nested callbacks.
Why Chain Promises?
Promise chaining offers several significant advantages:
- Readable Code Flow: Operations are arranged sequentially, similar to synchronous code
- Data Pipeline: Data flows naturally from one operation to the next
- Error Propagation: Errors automatically propagate down the chain
- Avoiding Deep Nesting: Eliminates the "pyramid of doom" problem seen with callbacks
- Modular Components: Each step in the chain can focus on a single responsibility
Think of Promise chaining as an assembly line in a factory. Each .then() is a workstation that receives an item, performs some operation on it, and passes the result to the next station. If any station encounters a problem, the item is redirected to the error handling area (.catch()).
Basic Promise Chaining
Let's start with the fundamentals of Promise chaining by exploring how to create simple sequences of asynchronous operations.
Creating a Chain of Operations
The basic pattern for Promise chaining is to return a value or a new Promise from each .then() handler:
fetch('/api/user/1')
.then(response => {
console.log('Raw response received');
return response.json(); // Returns a new Promise
})
.then(userData => {
console.log('User data:', userData);
return fetch(`/api/user/${userData.id}/posts`); // Returns another Promise
})
.then(response => {
return response.json(); // Returns another Promise
})
.then(postsData => {
console.log('Posts data:', postsData);
// Process user's posts
})
.catch(error => {
console.error('Error in chain:', error);
});
In this example, each step in the chain receives the result of the previous step's Promise and can either return a simple value or another Promise.
Returning Values vs. Returning Promises
It's important to understand the difference between returning a value and returning a Promise in a .then() handler:
// Chaining with simple values
Promise.resolve(1)
.then(value => {
console.log('Step 1:', value); // 1
return value + 1; // Return a simple value
})
.then(value => {
console.log('Step 2:', value); // 2
return value * 2; // Return a simple value
})
.then(value => {
console.log('Step 3:', value); // 4
});
// Chaining with nested Promises
Promise.resolve('user-id-123')
.then(userId => {
console.log('Got user ID:', userId);
return fetch(`/api/user/${userId}`); // Return a Promise
})
.then(response => {
// This runs when the fetch Promise resolves
return response.json(); // Return another Promise
})
.then(userData => {
// This runs when the json() Promise resolves
console.log('User data:', userData);
});
When you return a simple value, it's automatically wrapped in a resolved Promise. When you return a Promise, the next .then() in the chain waits for that Promise to settle.
Data Transformation in Chains
Promise chains are excellent for progressively transforming data through a series of operations:
// Data transformation chain
fetchUserData(userId)
.then(rawData => {
// Step 1: Parse and normalize the data
console.log('Raw data received');
return {
id: rawData.user_id,
name: rawData.user_name,
email: rawData.user_email,
isActive: rawData.status === 'active'
};
})
.then(userData => {
// Step 2: Enrich the data with additional information
console.log('User data normalized');
return fetchUserPreferences(userData.id)
.then(preferences => {
// Combine user data with preferences
return {
...userData,
preferences: preferences
};
});
})
.then(enrichedData => {
// Step 3: Prepare data for display
console.log('Data enriched with preferences');
return {
displayName: enrichedData.name,
contactInfo: enrichedData.email,
theme: enrichedData.preferences.theme || 'default',
language: enrichedData.preferences.language || 'en',
notifications: enrichedData.preferences.notifications || []
};
})
.then(displayData => {
// Step 4: Use the fully processed data
console.log('Display data prepared');
updateUserInterface(displayData);
})
.catch(error => {
console.error('Error in data processing chain:', error);
});
This pattern is similar to the "pipeline" or "stream" concept in functional programming, where data flows through a series of transformations.
Real-World Example: User Onboarding Flow
Let's see how Promise chaining can model a complex user onboarding flow in a web application:
function startUserOnboarding(email, password) {
// Step 1: Create the user account
return createUserAccount(email, password)
.then(newUser => {
// Step 2: Set up default preferences
console.log(`User created with ID: ${newUser.id}`);
return setDefaultPreferences(newUser.id)
.then(() => newUser); // Pass the user to the next step
})
.then(user => {
// Step 3: Create a welcome email
console.log(`Default preferences set for user: ${user.id}`);
return sendWelcomeEmail(user.email)
.then(() => user); // Pass the user to the next step
})
.then(user => {
// Step 4: Generate starter content
console.log(`Welcome email sent to: ${user.email}`);
return generateStarterContent(user.id)
.then(contentIds => ({ user, contentIds })); // Pass both user and content
})
.then(({ user, contentIds }) => {
// Step 5: Log the successful onboarding
console.log(`Starter content generated: ${contentIds.length} items`);
return logOnboardingSuccess(user.id)
.then(() => user.id); // Return just the user ID
})
.then(userId => {
// Step 6: Complete the process
console.log(`Onboarding logged for user: ${userId}`);
return {
success: true,
userId: userId,
message: 'User onboarding completed successfully'
};
})
.catch(error => {
// Centralized error handling for all steps
console.error('Onboarding failed:', error);
// You could add recovery or clean-up logic here
// For example, deleting the user account if late steps fail
return {
success: false,
error: error.message,
message: 'User onboarding failed'
};
});
}
This example demonstrates how Promise chaining allows you to model complex workflows with clear step progression and proper error handling, while avoiding the deep nesting that would occur with callbacks.
Advanced Chaining Patterns
Now let's explore some more advanced patterns and techniques for working with Promise chains.
Chain Branching and Rejoining
Sometimes you need to branch off from a main chain and then rejoin it later, which you can accomplish by storing the main Promise:
// Start with a main chain
const mainChain = fetchUserProfile(userId)
.then(profile => {
console.log('Profile fetched:', profile);
// Store data needed for later
userData = profile;
// Continue the main chain
return fetchUserPosts(profile.id);
})
.then(posts => {
console.log('Posts fetched:', posts.length);
// Store for later
userPosts = posts;
// This is the end of our "main chain" for now
});
// Create a branch from the main chain
const analysisChain = mainChain
.then(() => {
// This starts after mainChain completes
console.log('Starting analysis branch');
// Use data from main chain
return analyzeUserActivity(userData.id);
})
.then(activityReport => {
console.log('Activity analysis complete');
return activityReport;
});
// Create another branch
const recommendationChain = mainChain
.then(() => {
console.log('Starting recommendation branch');
// Use data from main chain
return generateRecommendations(userData.preferences, userPosts);
})
.then(recommendations => {
console.log('Recommendations generated');
return recommendations;
});
// Rejoin the branches
Promise.all([analysisChain, recommendationChain])
.then(([activityReport, recommendations]) => {
console.log('All branches complete, creating final report');
// Combine data from all branches
return createUserReport(userData, userPosts, activityReport, recommendations);
})
.then(finalReport => {
console.log('Final report created');
displayReport(finalReport);
})
.catch(error => {
console.error('Error in branched chain:', error);
});
This pattern is useful when you have multiple independent operations that depend on common initial data, and then need to combine their results.
Dynamic Chain Building
You can build Promise chains dynamically, which is useful when the number of steps isn't known in advance:
function processInSequence(items, processingFunction) {
// Start with a resolved Promise as the "previous" value
return items.reduce((previousPromise, item) => {
// Return a new Promise chained from the previous one
return previousPromise.then(resultsSoFar => {
// Process this item
return processingFunction(item).then(result => {
// Combine this result with previous results
return [...resultsSoFar, result];
});
});
}, Promise.resolve([])); // Start with empty array of results
}
// Example usage
const userIds = [101, 102, 103, 104, 105];
processInSequence(userIds, id => {
console.log(`Processing user ${id}`);
return fetchUserData(id);
})
.then(allResults => {
console.log(`Processed ${allResults.length} users:`, allResults);
})
.catch(error => {
console.error('Processing sequence failed:', error);
});
This technique creates a chain of Promises that process items one after another, waiting for each to complete before starting the next.
Conditional Chain Paths
You can create branches in your Promise chain based on conditions:
checkUserPermissions(userId, 'read-document', documentId)
.then(hasPermission => {
if (hasPermission) {
// Permission path
return fetchDocument(documentId);
} else {
// No permission path
throw new Error('Access denied');
}
})
.then(document => {
// This only runs if permission was granted
console.log('Document fetched:', document);
return processDocument(document);
})
.catch(error => {
if (error.message === 'Access denied') {
// Handle permission error specifically
console.error('Permission denied for document');
showAccessDeniedMessage();
} else {
// Handle other errors
console.error('Error fetching or processing document:', error);
showGenericErrorMessage();
}
});
This pattern allows for different processing paths based on conditions, with all paths eventually rejoining at the common error handler.
Chain Recovery
You can recover from errors in a chain by handling them in a .catch() and then returning a valid value:
fetchLatestData()
.then(data => {
console.log('Using latest data:', data);
return processData(data);
})
.catch(error => {
// Handle failed fetch by falling back to cached data
console.warn('Failed to fetch latest data, using cached data:', error);
return getCachedData().then(cachedData => {
console.log('Using cached data:', cachedData);
return processData(cachedData);
});
})
.then(processedData => {
// This runs regardless of whether we used latest or cached data
console.log('Data processing complete');
updateUI(processedData);
})
.catch(error => {
// This handles errors from either data source or processing
console.error('Complete failure, no data available:', error);
showDataError();
});
This technique is especially useful for implementing fallback strategies and graceful degradation in web applications.
Promise Composition with Promise.all, Promise.race, and More
Beyond simple chaining, JavaScript provides several methods for composing multiple Promises together. These composition methods allow you to coordinate multiple asynchronous operations in various ways.
Promise.all - Parallel Execution
Promise.all takes an iterable of Promises and returns a new Promise that fulfills when all the input Promises fulfill, or rejects if any input Promise rejects:
// Fetch data from multiple endpoints in parallel
Promise.all([
fetch('/api/users').then(res => res.json()),
fetch('/api/posts').then(res => res.json()),
fetch('/api/comments').then(res => res.json())
])
.then(([users, posts, comments]) => {
// All requests have completed successfully
console.log(`Fetched ${users.length} users`);
console.log(`Fetched ${posts.length} posts`);
console.log(`Fetched ${comments.length} comments`);
// Now we can work with all the data together
const enrichedPosts = enrichPostsWithData(posts, users, comments);
return enrichedPosts;
})
.catch(error => {
// If any of the requests fail, this executes
console.error('Failed to fetch required data:', error);
});
Key characteristics of Promise.all:
- All Promises run in parallel, not sequentially
- Results maintain the same order as the input Promises
- Fails fast - if any Promise rejects, the entire operation rejects immediately
- If all Promises succeed, you get an array of all results
Promise.race - First-to-Complete
Promise.race takes an iterable of Promises and returns a new Promise that settles as soon as the first Promise in the iterable settles:
// Implement a timeout for an operation
function fetchWithTimeout(url, timeout = 5000) {
// Create a Promise that rejects after the timeout
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Request timed out after ${timeout}ms`));
}, timeout);
});
// Create the fetch Promise
const fetchPromise = fetch(url).then(res => res.json());
// Race them - whichever settles first wins
return Promise.race([fetchPromise, timeoutPromise]);
}
// Example usage
fetchWithTimeout('/api/large-data', 3000)
.then(data => {
console.log('Data received in time:', data);
})
.catch(error => {
console.error('Request failed or timed out:', error.message);
});
Key characteristics of Promise.race:
- Settles as soon as any of the input Promises settle
- If the first to settle is fulfilled, race fulfills with that value
- If the first to settle is rejected, race rejects with that reason
- Other Promises continue running but their results are ignored
Promise.allSettled - Complete Resolution
Promise.allSettled takes an iterable of Promises and returns a Promise that fulfills when all Promises have settled, regardless of whether they fulfilled or rejected:
// Fetch multiple resources, handling individual failures
Promise.allSettled([
fetch('/api/critical-data').then(res => res.json()),
fetch('/api/optional-data-1').then(res => res.json()),
fetch('/api/optional-data-2').then(res => res.json())
])
.then(results => {
// results is an array of objects with status 'fulfilled' or 'rejected'
console.log('All operations completed (success or failure)');
// Process successful results
const successfulResults = results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
console.log(`${successfulResults.length} out of ${results.length} operations succeeded`);
// Log failures
results
.filter(result => result.status === 'rejected')
.forEach((result, index) => {
console.warn(`Operation at index ${index} failed:`, result.reason);
});
// Continue with whatever data we successfully retrieved
processAvailableData(successfulResults);
});
Key characteristics of Promise.allSettled:
- Always fulfills once all Promises have settled (never rejects)
- Returns detailed results for each Promise, including both successes and failures
- Useful when you need to attempt multiple operations and continue even if some fail
- Added in ES2020, but polyfills are available for older environments
Promise.any - First Success
Promise.any takes an iterable of Promises and returns a Promise that fulfills as soon as any of the input Promises fulfills:
// Try multiple data sources, use the first successful one
Promise.any([
fetchFromPrimaryAPI('/users/123'),
fetchFromBackupAPI('/users/123'),
fetchFromCache('users', '123')
])
.then(userData => {
// First API to successfully return data wins
console.log('Received user data from one of the sources:', userData);
updateUserProfile(userData);
})
.catch(error => {
// AggregateError containing all the individual errors
console.error('All data sources failed:', error.errors);
showDataRetrievalError();
});
Key characteristics of Promise.any:
- Fulfills as soon as any Promise fulfills
- Only rejects if all Promises reject, with an AggregateError containing all reasons
- Useful for "fall-forward" scenarios where you have multiple ways to complete an operation
- Added in ES2021, so polyfills may be needed for older browsers
Choosing the Right Composition Method
| Method | When to Use | Example Scenario |
|---|---|---|
| Promise.all | When you need all operations to succeed and want to wait for all to complete | Loading all required resources for a dashboard |
| Promise.race | When you want to take the first result, success or failure | Implementing timeouts, using the fastest data source |
| Promise.allSettled | When you want to attempt all operations regardless of individual failures | Batch operations where partial success is acceptable |
| Promise.any | When you want the first successful result and don't care which operation provides it | Redundant API calls for fault tolerance |
Real-World Example: Resilient Data Loading
Let's combine multiple composition techniques for a resilient data loading system:
function loadUserDashboard(userId) {
console.log(`Loading dashboard for user ${userId}`);
// Show loading state
showLoadingIndicator();
// 1. Load critical user data with fallback sources
const userDataPromise = Promise.any([
fetchUserFromMainAPI(userId),
fetchUserFromBackupAPI(userId),
fetchUserFromCache(userId)
]).catch(error => {
console.error('All sources for user data failed:', error);
// Provide minimal placeholder data to allow partial functionality
return { id: userId, name: 'Unknown User', isPlaceholder: true };
});
// 2. Load multiple dashboard components in parallel
const dashboardDataPromise = Promise.allSettled([
fetchUserStatistics(userId),
fetchRecentActivity(userId),
fetchNotifications(userId),
fetchRecommendations(userId)
]).then(results => {
// Process each result (success or failure)
return {
statistics: processResult(results[0], 'Statistics'),
activity: processResult(results[1], 'Activity'),
notifications: processResult(results[2], 'Notifications'),
recommendations: processResult(results[3], 'Recommendations')
};
});
// Helper to handle individual results
function processResult(result, componentName) {
if (result.status === 'fulfilled') {
console.log(`${componentName} loaded successfully`);
return {
data: result.value,
error: null,
loaded: true
};
} else {
console.warn(`${componentName} failed to load:`, result.reason);
return {
data: null,
error: result.reason,
loaded: false
};
}
}
// 3. Wait for both user data and dashboard data, then render
return Promise.all([userDataPromise, dashboardDataPromise])
.then(([userData, dashboardData]) => {
console.log('All data loaded, rendering dashboard');
// Hide loading indicator
hideLoadingIndicator();
// Render the dashboard with available data
renderDashboard({
user: userData,
...dashboardData
});
// Show warnings for missing components
Object.entries(dashboardData).forEach(([key, component]) => {
if (!component.loaded) {
showComponentError(key, component.error);
}
});
// Return the complete dashboard state
return {
user: userData,
...dashboardData,
fullyLoaded: !userData.isPlaceholder &&
Object.values(dashboardData).every(c => c.loaded)
};
})
.catch(error => {
// This should rarely happen since we handle failures at component level
console.error('Critical dashboard loading error:', error);
hideLoadingIndicator();
showDashboardError(error);
throw error; // Re-throw for caller to handle if needed
});
}
This example demonstrates a comprehensive approach to resilient data loading:
- Promise.any for critical user data with multiple fallback sources
- Promise.allSettled for non-critical dashboard components that can individually fail
- Promise.all to wait for the two primary groups of data before rendering
- Careful error handling at multiple levels
- Graceful degradation when some components fail to load
Practical Exercise: Building a Resource Loader
Let's apply what we've learned about Promise chaining and composition by building a practical resource loader utility for web applications.
Resource Loader Challenge
Create a ResourceLoader class with these features:
- Load different types of resources (JSON, images, text) with appropriate methods
- Implement caching to avoid reloading the same resources
- Support concurrent loading of multiple resources
- Provide progress tracking for batch operations
- Implement retries for failed resources
- Support pre-loading and lazy-loading strategies
Implementation
class ResourceLoader {
constructor(options = {}) {
// Configuration options
this.options = {
maxRetries: options.maxRetries || 2,
retryDelay: options.retryDelay || 1000,
cacheExpiry: options.cacheExpiry || 5 * 60 * 1000, // 5 minutes
...options
};
// Resource cache
this.cache = new Map();
// Currently loading resources
this.loading = new Map();
}
// Load a JSON resource
loadJSON(url, options = {}) {
return this._loadResource(url, {
...options,
processor: response => response.json()
});
}
// Load an image
loadImage(url, options = {}) {
return new Promise((resolve, reject) => {
// Check cache first
const cachedResource = this._getFromCache(url);
if (cachedResource) {
return resolve(cachedResource);
}
// If already loading this URL, return the existing Promise
if (this.loading.has(url)) {
return this.loading.get(url);
}
const img = new Image();
img.onload = () => {
this._addToCache(url, img);
this.loading.delete(url);
resolve(img);
};
img.onerror = () => {
this.loading.delete(url);
// Retry logic
if (options.retry !== false && (options.retryCount || 0) < this.options.maxRetries) {
console.warn(`Failed to load image ${url}, retrying...`);
setTimeout(() => {
this.loadImage(url, {
...options,
retryCount: (options.retryCount || 0) + 1
}).then(resolve).catch(reject);
}, this.options.retryDelay);
} else {
reject(new Error(`Failed to load image: ${url}`));
}
};
// Start loading
const loadPromise = new Promise((res, rej) => {
img.src = url;
});
this.loading.set(url, loadPromise);
return loadPromise;
});
}
// Load a text resource
loadText(url, options = {}) {
return this._loadResource(url, {
...options,
processor: response => response.text()
});
}
// Batch load multiple resources
loadBatch(resources, options = {}) {
// Configure progress tracking
const total = resources.length;
let completed = 0;
const results = [];
const progressCallback = options.onProgress || (() => {});
// Use reduce to create a sequential chain for each resource
if (options.sequential) {
// Process sequentially
return resources.reduce((chain, resource, index) => {
return chain.then(resultsArray => {
// Load the resource
return this._loadResourceByType(resource)
.then(result => {
// Update progress
completed++;
progressCallback({
loaded: completed,
total: total,
progress: (completed / total) * 100,
current: resource,
result: result
});
// Add to results and continue chain
resultsArray[index] = result;
return resultsArray;
})
.catch(error => {
// Handle error but continue chain
console.error(`Error loading resource ${resource.url}:`, error);
completed++;
progressCallback({
loaded: completed,
total: total,
progress: (completed / total) * 100,
current: resource,
error: error
});
// Add null for failed resource
resultsArray[index] = null;
return resultsArray;
});
});
}, Promise.resolve(Array(total).fill(null)));
} else {
// Process in parallel
const promises = resources.map(resource => {
return this._loadResourceByType(resource)
.then(result => {
// Update progress
completed++;
progressCallback({
loaded: completed,
total: total,
progress: (completed / total) * 100,
current: resource,
result: result
});
return result;
})
.catch(error => {
// Handle error
console.error(`Error loading resource ${resource.url}:`, error);
completed++;
progressCallback({
loaded: completed,
total: total,
progress: (completed / total) * 100,
current: resource,
error: error
});
// Return null for failed resource or throw based on failFast option
if (options.failFast) {
throw error;
}
return null;
});
});
return Promise.all(promises);
}
}
// Preload resources without returning them
preload(resources) {
// Start loading but don't wait for completion
resources.forEach(resource => {
this._loadResourceByType(resource).catch(err => {
console.warn(`Preloading failed for ${resource.url}:`, err);
});
});
// Return immediately
return Promise.resolve();
}
// Clear the cache
clearCache() {
this.cache.clear();
}
// Private method to load a resource with fetch
_loadResource(url, options = {}) {
// Check cache first
const cachedResource = this._getFromCache(url);
if (cachedResource) {
return Promise.resolve(cachedResource);
}
// If already loading this URL, return the existing Promise
if (this.loading.has(url)) {
return this.loading.get(url);
}
// Prepare fetch options
const fetchOptions = {
...options.fetchOptions
};
// Create the loading Promise
const loadPromise = fetch(url, fetchOptions)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
// Process the response based on type
return options.processor(response);
})
.then(data => {
// Cache the result
this._addToCache(url, data);
this.loading.delete(url);
return data;
})
.catch(error => {
this.loading.delete(url);
// Retry logic
if (options.retry !== false && (options.retryCount || 0) < this.options.maxRetries) {
console.warn(`Failed to load ${url}, retrying...`);
return new Promise(resolve => {
setTimeout(resolve, this.options.retryDelay);
}).then(() => {
return this._loadResource(url, {
...options,
retryCount: (options.retryCount || 0) + 1
});
});
}
throw error;
});
// Store the loading Promise
this.loading.set(url, loadPromise);
return loadPromise;
}
// Private method to load a resource based on its type
_loadResourceByType(resource) {
const { type, url, ...options } = resource;
switch (type) {
case 'json':
return this.loadJSON(url, options);
case 'image':
return this.loadImage(url, options);
case 'text':
return this.loadText(url, options);
default:
return Promise.reject(new Error(`Unknown resource type: ${type}`));
}
}
// Private method to check and retrieve from cache
_getFromCache(url) {
if (this.cache.has(url)) {
const { data, timestamp } = this.cache.get(url);
// Check if cache entry has expired
if (Date.now() - timestamp < this.options.cacheExpiry) {
return data;
} else {
// Remove expired entry
this.cache.delete(url);
}
}
return null;
}
// Private method to add to cache
_addToCache(url, data) {
this.cache.set(url, {
data: data,
timestamp: Date.now()
});
}
}
// Example usage
const loader = new ResourceLoader({
maxRetries: 3,
cacheExpiry: 10 * 60 * 1000 // 10 minutes
});
// Load individual resources
loader.loadJSON('/api/users')
.then(users => {
console.log('Users loaded:', users);
})
.catch(error => {
console.error('Failed to load users:', error);
});
// Load batch of resources with progress tracking
const resources = [
{ type: 'json', url: '/api/posts' },
{ type: 'image', url: '/images/header.jpg' },
{ type: 'json', url: '/api/comments' },
{ type: 'text', url: '/templates/post-template.html' }
];
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
loader.loadBatch(resources, {
onProgress: progress => {
console.log(`Loading progress: ${progress.progress.toFixed(0)}%`);
progressBar.style.width = `${progress.progress}%`;
progressText.textContent = `Loading ${progress.loaded} of ${progress.total} resources`;
}
})
.then(results => {
console.log('All resources loaded:', results);
progressText.textContent = 'Loading complete!';
})
.catch(error => {
console.error('Batch loading failed:', error);
progressText.textContent = 'Loading failed!';
});
HTML Setup for the Demo
<div class="resource-loader-demo">
<h3>Resource Loader Demo</h3>
<div class="progress-container">
<div id="progress-bar" class="progress-bar"></div>
</div>
<div id="progress-text">Waiting to start loading...</div>
<div class="actions">
<button id="load-single">Load Single Resource</button>
<button id="load-batch">Load Resource Batch</button>
<button id="preload">Preload Resources</button>
<button id="clear-cache">Clear Cache</button>
</div>
<div class="results">
<h4>Results</h4>
<pre id="results-area"></pre>
</div>
</div>
<style>
.resource-loader-demo {
padding: 20px;
background-color: #f5f5f5;
border-radius: 8px;
}
.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.2s;
}
.actions {
margin: 20px 0;
display: flex;
gap: 10px;
}
.results {
margin-top: 20px;
}
#results-area {
background-color: #333;
color: #fff;
padding: 10px;
border-radius: 5px;
min-height: 100px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
}
</style>
This exercise demonstrates several important concepts:
- Creating a flexible, reusable utility class with Promise-based methods
- Implementing resource caching and tracking in-progress operations
- Using Promise.all for parallel loading and reduce() for sequential loading
- Handling progress tracking and error management in complex async operations
- Building retry mechanisms for resilient resource loading
- Supporting different loading strategies (sequential, parallel, preloading)
A utility like this would be valuable in modern web applications, especially those dealing with many resources like games, media-rich websites, or data visualization tools.
Summary and Next Steps
In this lecture, we've explored:
- The fundamentals of Promise chaining and its benefits over nested callbacks
- Advanced chaining patterns like branching, conditional paths, and recovery
- Promise composition methods: Promise.all, Promise.race, Promise.allSettled, and Promise.any
- Choosing the right composition technique for different scenarios
- Building a practical resource loader that applies these concepts
Promise chaining and composition are powerful techniques that allow you to create clean, maintainable asynchronous code flows. They form the foundation of modern JavaScript application architecture and are essential skills for any JavaScript developer.
In our next lecture, we'll explore Promise static methods and utilities in more depth, including advanced techniques for working with Promises and common patterns used in real-world applications.
Additional Practice Exercises
- Sequential Processing: Create a function that processes an array of items sequentially with a delay between each item, returning a Promise that resolves with all results.
- Rate Limiter: Build a Promise-based rate limiter that allows only a certain number of operations to run concurrently, queuing the rest.
- Promise Middleware: Create a system where Promises pass through a series of middleware functions, similar to Express.js middleware.
- Promise Pool: Implement a Promise pool that limits the number of concurrent Promises running at once, useful for controlling API call frequency.