INFO 153A/253A - Front-End Web Architecture
UC Berkeley
Topics: Async/Await Mastery, Advanced Promises, Error Handling, Real-World Patterns
From Chapter 10 Mandatory Prep & Week 10
// From Chapter 10: Creating and consuming promises
const promise = new Promise((resolve, reject) => {
// Async operation
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve({ data: "Operation complete" });
} else {
reject(new Error("Operation failed"));
}
}, 1000);
});
// Consuming with .then/.catch
promise
.then(result => console.log(result))
.catch(error => console.error(error))
.finally(() => console.log("Cleanup"));
// Evolution of async patterns
// 1. Callback pattern (the old way)
function fetchData(callback) {
setTimeout(() => {
callback({ id: 1, name: "Student" });
}, 1000);
}
// 2. Promise pattern (better)
function fetchDataPromise() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id: 1, name: "Student" });
}, 1000);
});
}
// 3. Async/await pattern (modern - introduced Week 10)
async function fetchDataAsync() {
// Simulating API call
await new Promise(resolve => setTimeout(resolve, 1000));
return { id: 1, name: "Student" };
}
// From prep: Handling multiple promises simultaneously
const fetchCourses = fetch('/api/courses').then(r => r.json());
const fetchStudents = fetch('/api/students').then(r => r.json());
const fetchGrades = fetch('/api/grades').then(r => r.json());
// Wait for all to complete
Promise.all([fetchCourses, fetchStudents, fetchGrades])
.then(([courses, students, grades]) => {
console.log('All data loaded:', { courses, students, grades });
// Process all data together
})
.catch(error => {
console.error('One or more requests failed:', error);
// If ANY promise rejects, Promise.all rejects
});
// With async/await (Week 10 style)
async function loadAllData() {
try {
const [courses, students, grades] = await Promise.all([
fetch('/api/courses').then(r => r.json()),
fetch('/api/students').then(r => r.json()),
fetch('/api/grades').then(r => r.json())
]);
return { courses, students, grades };
} catch (error) {
throw new Error('Failed to load dashboard data');
}
}
Remember: JavaScript is single-threaded but has async capabilities through Web APIs
// From Week 10: Async in React components
function CourseList() {
const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Can't make useEffect async directly
async function fetchCourses() {
try {
const response = await fetch('/api/courses');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setCourses(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchCourses();
}, []);
// Component renders with loading/error/data states
}
What It Is, Why It Exists, and Problems It Solves
Definition: Async/await is syntactic sugar built on top of Promises that makes asynchronous code look and behave like synchronous code.
function fetchData(userId) {
return fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(user => {
return fetch(`/api/courses/${user.courseId}`);
})
.then(r => r.json())
.then(course => course)
.catch(err => console.error(err));
}
async function fetchData(userId) {
try {
const userRes = await fetch(`/api/users/${userId}`);
const user = await userRes.json();
const courseRes = await fetch(`/api/courses/${user.courseId}`);
const course = await courseRes.json();
return course;
} catch (err) {
console.error(err);
}
}
// Pyramid of doom (circa 2010)
getUser(userId, function(user) {
getCourses(user.id, function(courses) {
getGrades(courses[0].id, function(grades) {
calculateAvg(grades, function(avg) {
displayResult(avg, function() {
console.log('Done!');
});
});
});
});
});
// Flat, readable flow
async function display(userId) {
const user = await getUser(userId);
const courses = await getCourses(user.id);
const grades = await getGrades(courses[0].id);
const avg = await calculateAvg(grades);
await displayResult(avg);
console.log('Done!');
}
// Which request failed?
fetch('/api/user')
.then(r => {
if (!r.ok) throw new Error('User failed');
return r.json();
})
.then(user => fetch(`/api/courses/${user.id}`))
.then(r => {
if (!r.ok) throw new Error('Courses failed');
return r.json();
})
.catch(error => {
console.error('Something failed:', error);
});
// Clear error context
async function loadData() {
try {
const userRes = await fetch('/api/user');
if (!userRes.ok) throw new Error('User failed');
const user = await userRes.json();
const courseRes = await fetch(`/api/courses/${user.id}`);
if (!courseRes.ok) throw new Error('Courses failed');
const courses = await courseRes.json();
return { user, courses };
} catch (error) {
console.error('Load failed:', error.message);
throw error;
}
}
// Waits for each one
async function loadDashboard() {
const user = await fetch('/api/user')
.then(r => r.json()); // 200ms
const courses = await fetch('/api/courses')
.then(r => r.json()); // 200ms
const grades = await fetch('/api/grades')
.then(r => r.json()); // 200ms
return { user, courses, grades };
// Total: ~600ms
}
// Runs simultaneously
async function loadDashboard() {
const [user, courses, grades] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/courses').then(r => r.json()),
fetch('/api/grades').then(r => r.json())
]);
return { user, courses, grades };
// Total: ~200ms (3x faster!)
}
Under the Hood: Async functions are transformed into Promises by JavaScript
// What you write
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
return user;
}
// Conceptually becomes
function fetchUser(id) {
return new Promise((resolve, reject) => {
fetch(`/api/users/${id}`)
.then(res => res.json())
.then(user => resolve(user))
.catch(err => reject(err));
});
}
// Key behaviors
const result = fetchUser(123);
console.log(result);
// Promise { }
// Must use await or .then()
const user = await fetchUser(123);
console.log(user);
// { id: 123, name: "Student" }
// Even plain returns wrap in Promise
async function getNumber() {
return 42;
}
getNumber() // Promise.resolve(42)
async function processOrder(orderId) {
try {
const order = await getOrder(orderId);
const payment = await processPayment(order);
const ship = await createShipment(payment);
return ship;
} catch (error) {
await rollbackOrder(orderId);
throw error;
}
}
// Promise chains for transforms
fetch('/api/users')
.then(r => r.json())
.then(users => users.filter(u => u.active))
.then(active => active.map(u => u.email))
.then(emails => sendNotifications(emails))
.catch(handleError);
// Forgot await - returns Promise!
async function getUsername(id) {
const user = fetchUser(id); // Missing await!
return user.name;
// Error: Cannot read property
// 'name' of Promise
}
// What user actually is:
console.log(user);
// Promise { }
// Add await keyword
async function getUsername(id) {
const user = await fetchUser(id);
return user.name;
// Works! Returns the actual name
}
// What user actually is:
console.log(user);
// { id: 1, name: "Kay" }
// Waits for each one
async function processUsers(userIds) {
const results = [];
for (const id of userIds) {
const user = await fetchUser(id);
results.push(user);
}
return results;
}
// 100 users = ~20 seconds!
// All at once
async function processUsers(userIds) {
const promises = userIds.map(
id => fetchUser(id)
);
return await Promise.all(promises);
}
// 100 users = ~200ms!
// Unhandled rejection!
async function loadData() {
const data = await fetch('/api/data')
.then(r => r.json());
return data;
// If fetch fails, app crashes!
}
// Graceful fallback
async function loadData() {
try {
const data = await fetch('/api/data')
.then(r => r.json());
return data;
} catch (error) {
console.error('Load failed:', error);
return getDefaultData();
}
}
Beyond Promise.all - Race, AllSettled, and Any
// Promise.race settles with the first promise to settle
async function fetchWithTimeout(url, timeout = 5000) {
const fetchPromise = fetch(url);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), timeout);
});
// Race between fetch and timeout
return Promise.race([fetchPromise, timeoutPromise]);
}
// Usage example
async function getStudentData() {
try {
const response = await fetchWithTimeout('/api/students', 3000);
const data = await response.json();
console.log('Got data:', data);
} catch (error) {
if (error.message === 'Request timeout') {
console.error('Request took too long!');
// Show cached data or retry
} else {
console.error('Network error:', error);
}
}
}
// Promise.allSettled waits for all, doesn't fail fast
async function loadDashboardData() {
const results = await Promise.allSettled([
fetch('/api/user-profile').then(r => r.json()),
fetch('/api/notifications').then(r => r.json()),
fetch('/api/recent-activity').then(r => r.json()),
fetch('/api/recommendations').then(r => r.json())
]);
// Process each result individually
const dashboard = {
profile: null,
notifications: [],
activity: [],
recommendations: []
};
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
switch(index) {
case 0: dashboard.profile = result.value; break;
case 1: dashboard.notifications = result.value; break;
case 2: dashboard.activity = result.value; break;
case 3: dashboard.recommendations = result.value; break;
}
} else {
console.error(`Failed to load section ${index}:`, result.reason);
}
});
return dashboard; // Partial data is better than no data
}
// Promise.any resolves with first successful promise
async function fetchFromFastestCDN(resource) {
try {
const result = await Promise.any([
fetch(`https://cdn1.example.com/${resource}`),
fetch(`https://cdn2.example.com/${resource}`),
fetch(`https://cdn3.example.com/${resource}`),
fetch(`https://backup.example.com/${resource}`)
]);
console.log('Got resource from fastest CDN');
return result;
} catch (error) {
// AggregateError: ALL promises rejected
console.error('All CDNs failed:', error.errors);
throw new Error('Resource unavailable from all sources');
}
}
// Practical example: Multi-region API fallback
async function getDataWithFallback(studentId) {
try {
const data = await Promise.any([
fetchFromRegion('us-west', studentId),
fetchFromRegion('us-east', studentId),
fetchFromRegion('europe', studentId),
fetchFromCache(studentId) // Local fallback
]);
return data;
} catch (aggregateError) {
// Only fails if ALL attempts fail
console.error('All attempts failed:', aggregateError.errors);
return getDefaultStudentData();
}
}
// Decision guide for Promise methods
const examples = {
// Use Promise.all when you need everything
all: "Loading user + preferences + permissions",
// Use Promise.race for timeouts
race: "API call with 5-second timeout",
// Use Promise.allSettled for optional data
allSettled: "Dashboard with independent widgets",
// Use Promise.any for fallback/redundancy
any: "Multi-region API with fallbacks"
};
// Practical comparison
async function demonstratePromiseMethods() {
const promises = [
delay(100, 'fast'),
delay(200, 'medium'),
delayReject(150, 'error'),
delay(300, 'slow')
];
// Different behaviors:
const all = Promise.all(promises); // Fails at 150ms
const race = Promise.race(promises); // Resolves at 100ms
const allSettled = Promise.allSettled(promises); // Waits 300ms
const any = Promise.any(promises); // Resolves at 100ms
}
Professional Async Error Patterns
// Professional error handling with custom error types
class APIError extends Error {
constructor(message, status, endpoint) {
super(message);
this.name = 'APIError';
this.status = status;
this.endpoint = endpoint;
this.timestamp = new Date().toISOString();
}
}
async function robustFetch(url, options = {}) {
const maxRetries = 3;
const timeout = options.timeout || 10000;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new APIError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
url
);
}
return await response.json();
} catch (error) {
if (attempt === maxRetries) {
// Log to error tracking service
console.error('Final attempt failed:', error);
throw error;
}
// Exponential backoff
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
}
// Async error boundary pattern
class AsyncBoundary {
constructor() {
this.errors = [];
this.handlers = new Map();
}
async wrap(operation, context = 'unknown') {
try {
const result = await operation();
return { success: true, data: result };
} catch (error) {
this.errors.push({ error, context, timestamp: Date.now() });
// Check for specific error handlers
for (const [ErrorType, handler] of this.handlers) {
if (error instanceof ErrorType) {
return { success: false, fallback: await handler(error) };
}
}
// Default fallback
return { success: false, error: error.message };
}
}
onError(ErrorType, handler) {
this.handlers.set(ErrorType, handler);
return this;
}
}
// Usage example
const boundary = new AsyncBoundary()
.onError(APIError, async (err) => {
if (err.status === 404) return { empty: true };
if (err.status === 401) await refreshAuth();
return null;
})
.onError(TypeError, () => ({ error: 'Invalid data format' }));
const result = await boundary.wrap(
() => fetchUserData(userId),
'user-data-fetch'
);
// Common async error scenarios and solutions
const ErrorHandlers = {
// Network errors
async handleNetworkError(error) {
if (!navigator.onLine) {
return { offline: true, cached: await getCachedData() };
}
throw error; // Re-throw if not offline
},
// Auth errors with token refresh
async handle401(refreshToken) {
try {
const newToken = await fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken })
}).then(r => r.json());
localStorage.setItem('token', newToken);
return { retry: true, token: newToken };
} catch {
// Refresh failed, need re-login
window.location.href = '/login';
}
},
// Rate limiting with retry-after
async handle429(retryAfter = 60) {
console.warn(`Rate limited, waiting ${retryAfter}s`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
return { retry: true };
},
// Data validation errors
validateResponse(data, schema) {
const errors = [];
for (const [key, type] of Object.entries(schema)) {
if (typeof data[key] !== type) {
errors.push(`${key} should be ${type}`);
}
}
if (errors.length > 0) {
throw new ValidationError(errors);
}
return data;
}
};
// Circuit breaker pattern for failing services
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.nextAttempt = Date.now();
}
async call(asyncFn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}
try {
const result = await asyncFn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
console.error('Circuit breaker opened, too many failures');
}
}
}
// Usage
const apiBreaker = new CircuitBreaker(3, 30000);
try {
const data = await apiBreaker.call(() => fetch('/flaky-api'));
} catch (error) {
// Use fallback or cached data
}
Production Patterns for Complex Async Scenarios
// Production-ready retry mechanism
async function retryWithBackoff(
fn,
maxRetries = 3,
baseDelay = 1000,
maxDelay = 30000,
shouldRetry = (error) => true
) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// Check if we should retry this type of error
if (!shouldRetry(error)) {
throw error;
}
if (attempt < maxRetries - 1) {
// Calculate delay with jitter to prevent thundering herd
const delay = Math.min(
baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
maxDelay
);
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
// Usage with smart retry logic
const fetchWithSmartRetry = async (url) => {
return retryWithBackoff(
() => fetch(url).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}),
5, // max retries
1000, // base delay
30000, // max delay
(error) => {
// Only retry on network errors or 5xx errors
return !error.message.includes('HTTP 4');
}
);
};
// Prevent duplicate requests for same data
class RequestCache {
constructor(ttl = 60000) {
this.cache = new Map();
this.pending = new Map();
this.ttl = ttl;
}
async fetch(key, fetchFn) {
// Return cached if still fresh
if (this.cache.has(key)) {
const { data, timestamp } = this.cache.get(key);
if (Date.now() - timestamp < this.ttl) {
console.log(`Cache hit for ${key}`);
return data;
}
this.cache.delete(key);
}
// Deduplicate concurrent requests
if (this.pending.has(key)) {
console.log(`Deduplicating request for ${key}`);
return this.pending.get(key);
}
// Make the request
const promise = fetchFn().then(data => {
this.cache.set(key, { data, timestamp: Date.now() });
this.pending.delete(key);
return data;
}).catch(error => {
this.pending.delete(key);
throw error;
});
this.pending.set(key, promise);
return promise;
}
invalidate(pattern) {
// Invalidate matching cache entries
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key);
}
}
}
}
// Usage
const apiCache = new RequestCache(30000); // 30s TTL
const userData = await apiCache.fetch(
`user-${userId}`,
() => fetch(`/api/users/${userId}`).then(r => r.json())
);
// Modern request cancellation with AbortController
class CancellableRequest {
constructor() {
this.controllers = new Map();
}
async fetch(id, url, options = {}) {
// Cancel previous request with same ID
this.cancel(id);
// Create new controller
const controller = new AbortController();
this.controllers.set(id, controller);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log(`Request ${id} was cancelled`);
return null;
}
throw error;
} finally {
this.controllers.delete(id);
}
}
cancel(id) {
const controller = this.controllers.get(id);
if (controller) {
controller.abort();
this.controllers.delete(id);
}
}
cancelAll() {
for (const controller of this.controllers.values()) {
controller.abort();
}
this.controllers.clear();
}
}
// Usage: Search with automatic cancellation
const requests = new CancellableRequest();
async function search(query) {
if (!query) return [];
// Same ID cancels previous search
return requests.fetch('search', `/api/search?q=${query}`);
}
// User types: "j" -> "ja" -> "jav" -> "java"
// Only final "java" request completes
// Smart polling with backoff and conditions
class SmartPoller {
constructor(fn, options = {}) {
this.fn = fn;
this.interval = options.interval || 5000;
this.maxInterval = options.maxInterval || 30000;
this.shouldContinue = options.shouldContinue || (() => true);
this.onError = options.onError || console.error;
this.running = false;
this.timer = null;
}
async start() {
if (this.running) return;
this.running = true;
this.currentInterval = this.interval;
const poll = async () => {
if (!this.running) return;
try {
const result = await this.fn();
// Check if we should continue polling
if (!this.shouldContinue(result)) {
this.stop();
return;
}
// Reset interval on success
this.currentInterval = this.interval;
} catch (error) {
this.onError(error);
// Increase interval on error (backoff)
this.currentInterval = Math.min(
this.currentInterval * 1.5,
this.maxInterval
);
}
// Schedule next poll
if (this.running) {
this.timer = setTimeout(poll, this.currentInterval);
}
};
// Start immediately
poll();
}
stop() {
this.running = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}
// Usage: Poll for job completion
const jobPoller = new SmartPoller(
() => fetch(`/api/jobs/${jobId}`).then(r => r.json()),
{
interval: 2000,
maxInterval: 10000,
shouldContinue: (job) => job.status === 'processing',
onError: (err) => console.error('Poll failed:', err)
}
);
jobPoller.start();
// Control concurrent async operations
class AsyncQueue {
constructor(concurrency = 2) {
this.concurrency = concurrency;
this.running = 0;
this.queue = [];
this.results = new Map();
}
async add(id, asyncFn) {
return new Promise((resolve, reject) => {
this.queue.push({ id, asyncFn, resolve, reject });
this.process();
});
}
async process() {
if (this.running >= this.concurrency || this.queue.length === 0) {
return;
}
this.running++;
const { id, asyncFn, resolve, reject } = this.queue.shift();
try {
console.log(`Processing ${id}, queue size: ${this.queue.length}`);
const result = await asyncFn();
this.results.set(id, result);
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.process(); // Process next item
}
}
async drain() {
while (this.queue.length > 0 || this.running > 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
}
// Usage: Limit concurrent file uploads
const uploadQueue = new AsyncQueue(3); // Max 3 concurrent uploads
async function uploadFiles(files) {
const uploads = files.map((file, index) =>
uploadQueue.add(`file-${index}`, () => uploadFile(file))
);
const results = await Promise.all(uploads);
console.log('All files uploaded:', results);
}
Remember: These patterns prevent production failures and improve UX