DEV Community

Cover image for JavaScript Clean Code Mastery: Part 4 - Async/Await and Error Handling That Actually Works
sizan mahmud0
sizan mahmud0

Posted on

JavaScript Clean Code Mastery: Part 4 - Async/Await and Error Handling That Actually Works

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

✅ 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 };
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

✅ 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

✅ 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);
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

✅ 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();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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)