Welcome Back to Clean Code!
In Part 1, we mastered naming. In Part 2, we conquered functions. In Part 3, we unleashed modern JavaScript features. Today, we're tackling the monster that haunts every JavaScript developer: asynchronous code and error handling.
I once spent 8 hours debugging production code that had a single missing .catch(). The app silently swallowed errors, and users saw blank screens with no explanation. Never again.
Today's Mission:
- Escape callback hell with async/await
- Handle errors properly (stop swallowing them!)
- Use Promise.all() for parallel operations
- Write robust try/catch blocks
- Handle async errors in event handlers
Let's transform your async nightmares into clean, maintainable code.
Practice 1: Escape Callback Hell with Async/Await
The Problem: Nested callbacks (callback hell) are impossible to read and debug.
❌ Bad: Callback Hell (The Pyramid of Doom)
function getUserData(userId, callback) {
getUser(userId, (err, user) => {
if (err) {
callback(err);
return;
}
getOrders(user.id, (err, orders) => {
if (err) {
callback(err);
return;
}
getPayments(user.id, (err, payments) => {
if (err) {
callback(err);
return;
}
getReviews(user.id, (err, reviews) => {
if (err) {
callback(err);
return;
}
callback(null, { user, orders, payments, reviews });
});
});
});
});
}
// Using it is also messy
getUserData(123, (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
Problems:
- 6 levels of nesting (nightmare to read)
- Error handling repeated 4 times
- Hard to add new operations
- Difficult to test
✅ Good: Async/Await (Clean and Sequential)
async function getUserData(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const payments = await getPayments(user.id);
const reviews = await getReviews(user.id);
return { user, orders, payments, reviews };
} catch (error) {
console.error('Failed to fetch user data:', error.message);
throw error; // Re-throw for caller to handle
}
}
// Using it is clean
try {
const data = await getUserData(123);
console.log(data);
} catch (error) {
console.error('Error:', error);
}
Benefits:
- Reads like synchronous code (top to bottom)
- Single try/catch for all errors
- Easy to add new operations
- Testable and maintainable
Practice 2: Use Promise.all() for Parallel Operations
The Problem: Sequential awaits waste time when operations are independent.
❌ Bad: Sequential Awaits (Slow)
async function getDashboardData(userId) {
// These DON'T depend on each other, but run sequentially!
const user = await getUser(userId); // Wait 50ms
const orders = await getOrders(userId); // Wait 100ms
const products = await getProducts(); // Wait 80ms
const notifications = await getNotifications(userId); // Wait 60ms
// Total time: 50 + 100 + 80 + 60 = 290ms
return { user, orders, products, notifications };
}
✅ Good: Parallel Execution with Promise.all()
async function getDashboardData(userId) {
// Launch all requests simultaneously!
const [user, orders, products, notifications] = await Promise.all([
getUser(userId),
getOrders(userId),
getProducts(),
getNotifications(userId)
]);
// Total time: max(50, 100, 80, 60) = 100ms (2.9x faster!)
return { user, orders, products, notifications };
}
When to Use Promise.all():
- Operations are independent (don't depend on each other)
- You need all results to continue
- Want maximum speed
Warning: If one promise fails, all fail. Use Promise.allSettled() if you want to continue even when some fail.
Advanced: Promise.allSettled() for Partial Success
async function getDashboardData(userId) {
const results = await Promise.allSettled([
getUser(userId),
getOrders(userId),
getProducts(),
getNotifications(userId)
]);
// Handle partial failures
const data = {};
results.forEach((result, index) => {
const keys = ['user', 'orders', 'products', 'notifications'];
if (result.status === 'fulfilled') {
data[keys[index]] = result.value;
} else {
console.error(`${keys[index]} failed:`, result.reason);
data[keys[index]] = null; // Set default
}
});
return data;
}
// Now the dashboard loads even if one API fails!
Practice 3: Never Swallow Errors
The Problem: Empty catch blocks hide bugs and make debugging impossible.
❌ Bad: Swallowing Errors
async function fetchUserData(userId) {
try {
const user = await api.getUser(userId);
return user;
} catch (error) {
// Silent failure - nobody knows it broke!
return null;
}
}
// Even worse
try {
await riskyOperation();
} catch (e) {
// Empty catch - error disappears into void
}
Why This Is Terrible:
- Bugs hide in production
- Users see blank screens with no explanation
- Debugging takes hours (no error logs)
- Silent data corruption possible
✅ Good: Proper Error Handling
async function fetchUserData(userId) {
try {
const user = await api.getUser(userId);
return user;
} catch (error) {
// Log with context for debugging
console.error('Failed to fetch user:', {
userId,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
// Re-throw or return error object (don't swallow!)
throw new Error(`Unable to load user ${userId}: ${error.message}`);
}
}
// Usage with proper error handling
try {
const user = await fetchUserData(123);
displayUser(user);
} catch (error) {
showErrorMessage('Failed to load user data. Please try again.');
logErrorToMonitoring(error); // Send to Sentry, etc.
}
Practice 4: Create Custom Error Classes
The Problem: Generic errors don't tell you what went wrong or how to handle it.
❌ Bad: Generic Errors
async function createUser(userData) {
if (!userData.email) {
throw new Error('Email is required');
}
try {
return await api.createUser(userData);
} catch (error) {
throw new Error('User creation failed');
}
}
// How do you handle these differently?
try {
await createUser({ name: 'Alice' });
} catch (error) {
// Is this a validation error or network error? 🤷♂️
console.error(error.message);
}
✅ Good: Custom Error Classes
// Define custom error types
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
class NetworkError extends Error {
constructor(message, statusCode) {
super(message);
this.name = 'NetworkError';
this.statusCode = statusCode;
}
}
class NotFoundError extends Error {
constructor(resource, id) {
super(`${resource} with id ${id} not found`);
this.name = 'NotFoundError';
this.resource = resource;
this.id = id;
}
}
// Use specific error types
async function createUser(userData) {
// Validation
if (!userData.email) {
throw new ValidationError('Email is required');
}
if (!userData.email.includes('@')) {
throw new ValidationError('Invalid email format');
}
// Network operation
try {
return await api.createUser(userData);
} catch (error) {
if (error.response?.status === 404) {
throw new NotFoundError('User endpoint', 'create');
}
throw new NetworkError('Failed to create user', error.response?.status);
}
}
// Handle different errors appropriately
try {
await createUser({ name: 'Alice' });
} catch (error) {
if (error instanceof ValidationError) {
showFormError(error.message); // Show in form
} else if (error instanceof NetworkError) {
showNotification('Network error. Please check your connection.');
retryLater();
} else if (error instanceof NotFoundError) {
showNotification('Service unavailable. Contact support.');
} else {
showNotification('An unexpected error occurred.');
logErrorToSentry(error);
}
}
Practice 5: Handle Async Errors in Event Handlers
The Problem: Async errors in event handlers are silently swallowed.
❌ Bad: Unhandled Async Errors
// This error disappears silently!
button.addEventListener('click', async () => {
const data = await fetchData(); // If this fails, no one knows
updateUI(data);
});
// Event handlers don't catch async errors
document.getElementById('submit').addEventListener('click', async (e) => {
e.preventDefault();
const result = await submitForm(); // Error swallowed
console.log('Success!'); // This never runs if error occurs
});
✅ Good: Explicit Try/Catch in Handlers
button.addEventListener('click', async () => {
try {
showLoading(true);
const data = await fetchData();
updateUI(data);
showSuccessMessage('Data loaded successfully!');
} catch (error) {
console.error('Failed to fetch data:', error);
showErrorMessage('Failed to load data. Please try again.');
} finally {
showLoading(false); // Always hide loading
}
});
// Better: Extract to named async function
async function handleSubmit(e) {
e.preventDefault();
try {
disableSubmitButton();
showLoadingSpinner();
const formData = new FormData(e.target);
const result = await submitForm(formData);
showSuccessNotification('Form submitted successfully!');
redirectToSuccessPage();
} catch (error) {
console.error('Form submission failed:', error);
if (error instanceof ValidationError) {
displayFormErrors(error.fields);
} else {
showErrorNotification('Submission failed. Please try again.');
}
} finally {
enableSubmitButton();
hideLoadingSpinner();
}
}
document.getElementById('submit').addEventListener('click', handleSubmit);
Practice 6: Use Finally for Cleanup
The Problem: Cleanup code gets duplicated in try and catch blocks.
❌ Bad: Duplicated Cleanup
async function fetchAndDisplay() {
showLoadingSpinner();
try {
const data = await fetchData();
displayData(data);
hideLoadingSpinner(); // Duplicated
} catch (error) {
showError(error);
hideLoadingSpinner(); // Duplicated
}
}
✅ Good: Finally Block
async function fetchAndDisplay() {
try {
showLoadingSpinner();
const data = await fetchData();
displayData(data);
} catch (error) {
console.error('Fetch failed:', error);
showError(error);
} finally {
hideLoadingSpinner(); // Runs no matter what!
}
}
// Real-world example: File operations
async function processFile(filePath) {
let fileHandle = null;
try {
fileHandle = await openFile(filePath);
const content = await fileHandle.read();
const processed = processContent(content);
await fileHandle.write(processed);
return { success: true };
} catch (error) {
console.error('File processing failed:', error);
return { success: false, error: error.message };
} finally {
// Always close file, even if error occurred
if (fileHandle) {
await fileHandle.close();
}
}
}
Real-World Example: E-Commerce Checkout Flow
Let's put everything together in a real checkout function:
class CheckoutError extends Error {
constructor(message, code) {
super(message);
this.name = 'CheckoutError';
this.code = code;
}
}
async function processCheckout(cart, paymentInfo, shippingAddress) {
// Show loading state
updateCheckoutUI({ loading: true });
try {
// Validate cart (synchronous)
if (cart.items.length === 0) {
throw new ValidationError('Cart is empty');
}
// Parallel operations (independent)
const [inventory, shippingRates, taxInfo] = await Promise.all([
checkInventory(cart.items),
calculateShipping(shippingAddress, cart.weight),
getTaxRates(shippingAddress.state)
]);
// Validate inventory
if (!inventory.available) {
throw new CheckoutError('Some items are out of stock', 'OUT_OF_STOCK');
}
// Calculate total
const subtotal = calculateSubtotal(cart);
const shipping = shippingRates.standard;
const tax = subtotal * taxInfo.rate;
const total = subtotal + shipping + tax;
// Process payment (sequential - depends on total)
const payment = await processPayment({
amount: total,
method: paymentInfo
});
if (!payment.success) {
throw new CheckoutError('Payment failed', 'PAYMENT_FAILED');
}
// Create order (sequential - depends on payment)
const order = await createOrder({
cart,
payment,
shipping: shippingAddress,
total
});
// Send confirmation (fire and forget - don't wait)
sendOrderConfirmation(order.id).catch(error => {
console.error('Failed to send confirmation email:', error);
// Don't fail checkout if email fails
});
// Success!
updateCheckoutUI({
loading: false,
success: true,
orderId: order.id
});
return order;
} catch (error) {
console.error('Checkout failed:', {
error: error.message,
code: error.code,
timestamp: new Date().toISOString()
});
// Handle different error types
if (error instanceof ValidationError) {
updateCheckoutUI({
loading: false,
error: error.message
});
} else if (error instanceof CheckoutError) {
if (error.code === 'OUT_OF_STOCK') {
updateCheckoutUI({
loading: false,
error: 'Some items are no longer available. Please update your cart.'
});
} else if (error.code === 'PAYMENT_FAILED') {
updateCheckoutUI({
loading: false,
error: 'Payment failed. Please check your payment information.'
});
}
} else {
updateCheckoutUI({
loading: false,
error: 'An unexpected error occurred. Please try again.'
});
}
throw error; // Re-throw for monitoring/logging
} finally {
// Always cleanup
releaseCheckoutLock();
saveCartState();
}
}
What Makes This Clean:
- ✅ Clear error types (ValidationError, CheckoutError)
- ✅ Parallel operations where possible (Promise.all)
- ✅ Sequential where necessary (payment → order)
- ✅ Proper error handling with specific messages
- ✅ Finally block for cleanup
- ✅ Fire-and-forget for non-critical operations
Quick Wins Checklist for Part 4
Audit your async code:
✅ Are you using async/await instead of callbacks?
✅ Do you have try/catch around all awaits?
✅ Are independent operations running in parallel? (Promise.all)
✅ Are errors logged with context? (not swallowed)
✅ Do you have custom error types? (easier error handling)
✅ Are event handlers wrapped in try/catch?
✅ Are you using finally for cleanup?
Part 4 Conclusion: Async Code Doesn't Have to Be Scary
Async JavaScript used to be a nightmare of nested callbacks. But with modern features:
Async/Await - Code reads top-to-bottom (like synchronous)
Promise.all() - Run operations in parallel (2-3x faster)
Custom Errors - Handle different failures appropriately
Try/Catch/Finally - Robust error handling with cleanup
Challenge: Find your deepest nested callback. Refactor it to async/await. Share the before/after nesting level in comments! 📊
Coming Up in Part 5: Arrays, Loops & Immutability 🚀
Next time, we'll master:
- Array methods (map, filter, reduce) over loops
- Immutable array operations
- Performance considerations
- Functional programming patterns
Transform your for-loops into elegant, declarative code!
Ready to conquer async code? 👏 Clap for clean async! (50 claps available)
Don't miss Part 5! 🔔 Follow me - Arrays and immutability coming in 3 days!
What's your biggest async pain point? 💬 Share in comments - let's solve it together!
Help your team handle errors better! 📤 Share this guide - proper error handling saves hours of debugging.
This is Part 4 of the 7-part "JavaScript Clean Code Mastery" series.
← Part 3: Modern JavaScript Features | Part 5: Arrays, Loops & Immutability →
Tags: #JavaScript #AsyncAwait #ErrorHandling #Promises #CleanCode #WebDevelopment #Programming #ES6 #AsyncProgramming
Top comments (0)