Promises in JavaScript

Promise Chaining and Composition

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.

graph LR A[Initial Promise] -->|.then()| B[Promise 2] B -->|.then()| C[Promise 3] C -->|.then()| D[Promise 4] D -->|.catch()| E[Error Handler] style A fill:#f9f,stroke:#333 style B fill:#bbf,stroke:#333 style C fill:#bbf,stroke:#333 style D fill:#bbf,stroke:#333 style E fill:#fbb,stroke:#333

Why Chain Promises?

Promise chaining offers several significant advantages:

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);
});
graph TD A[Start] --> B[Fetch Users] A --> C[Fetch Posts] A --> D[Fetch Comments] B --> E{Promise.all} C --> E D --> E E --> F[Process All Data] E --> G[Catch Any Errors] style E fill:#f9f,stroke:#333

Key characteristics of Promise.all:

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);
    });
graph TD A[Start] --> B[Fetch Data] A --> C[Timeout Timer] B --> D{Promise.race} C --> D D --> E[First to Complete Wins] style D fill:#f9f,stroke:#333

Key characteristics of Promise.race:

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:

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:

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:

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:

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:

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:

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

  1. 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.
  2. Rate Limiter: Build a Promise-based rate limiter that allows only a certain number of operations to run concurrently, queuing the rest.
  3. Promise Middleware: Create a system where Promises pass through a series of middleware functions, similar to Express.js middleware.
  4. Promise Pool: Implement a Promise pool that limits the number of concurrent Promises running at once, useful for controlling API call frequency.

Additional Resources