The Await Keyword and Error Handling

Mastering the await operator and managing exceptions in asynchronous code

Introduction to the Await Keyword

The await keyword is a fundamental part of the async/await syntax in JavaScript. It's what makes asynchronous code read like synchronous code, dramatically improving readability and maintainability.

While we've already been introduced to await in our previous lecture, this session will delve deeper into how it works, its nuanced behavior, and how to effectively handle errors in async functions.

graph TD A["await promise"] --> B{"Promise Status"} B -->|"Pending"| C["Pause Function Execution"] C --> D["Resume when Promise settles"] B -->|"Fulfilled"| E["Continue with Promise value"] B -->|"Rejected"| F["Throw Exception"] style A fill:#e3f2fd,stroke:#333,stroke-width:1px style B fill:#fff3e0,stroke:#333,stroke-width:1px style C fill:#e8f5e9,stroke:#333,stroke-width:1px style F fill:#ffebee,stroke:#333,stroke-width:1px

How Await Works

The Await Expression

The await expression causes an async function to pause execution until a Promise is settled (fulfilled or rejected). When the Promise settles, the await expression evaluates to the fulfillment value of the Promise, or throws the rejection reason.

const result = await promise; Start Pause Resume Promise pending (execution paused)

What Can Be Awaited?

The await operator can be used with any "thenable" object – an object with a then() method. This includes native Promises and any Promise-compatible libraries.

// Awaiting a native Promise
const response = await fetch('/api/data');

// Awaiting a Promise created with the constructor
const delay = await new Promise(resolve => setTimeout(resolve, 1000));

// Awaiting a thenable object (not a true Promise, but compatible)
const thenable = {
  then(resolve, reject) {
    resolve('Result from thenable');
  }
};
const result = await thenable; // Works just like a Promise

// Awaiting a non-Promise value (automatically wrapped in a resolved Promise)
const value = await 42; // Equivalent to await Promise.resolve(42)

// Awaiting a function that returns a Promise
async function getData() {
  return { id: 1, name: 'Example' };
}
const data = await getData();

When you await a non-Promise value, JavaScript automatically wraps it in a resolved Promise. This makes await very flexible and allows for consistent error handling patterns.

The Execution Flow of Await

To truly master await, it's important to understand exactly how it affects the execution flow of your code.

sequenceDiagram participant MF as Main Function participant EL as Event Loop participant P as Promise MF->>P: await promise Note over MF: Function execution paused MF->>EL: Return control to event loop EL->>P: Monitor promise Note over EL: Continue executing other tasks P->>P: Asynchronous work P-->>EL: Promise resolves EL->>MF: Resume function execution Note over MF: Continue with promise result

Step-by-Step Execution

  1. JavaScript encounters the await expression in an async function.
  2. If the awaited value is already resolved, execution continues immediately.
  3. If the awaited value is pending, the function's execution is paused.
  4. Control is returned to the JavaScript event loop, which can run other code.
  5. When the awaited Promise settles, the function resumes from where it was paused.
  6. If the Promise fulfilled, the await expression evaluates to the fulfillment value.
  7. If the Promise rejected, an exception is thrown at the await line.
// Example to demonstrate execution flow
async function demonstrateAwait() {
  console.log('1. Start of function');
  
  console.log('2. Before first await');
  const result1 = await Promise.resolve('First result');
  console.log('3. After first await:', result1);
  
  console.log('4. Before second await');
  try {
    const result2 = await Promise.reject('Error occurred');
    console.log('This line never executes'); // Skipped due to rejection
  } catch (error) {
    console.log('5. Caught error:', error);
  }
  
  console.log('6. End of function');
  return 'Done';
}

console.log('A. Before calling async function');
demonstrateAwait().then(result => {
  console.log('C. Async function completed with result:', result);
});
console.log('B. After calling async function');

// Output sequence:
// A. Before calling async function
// 1. Start of function
// 2. Before first await
// B. After calling async function
// 3. After first await: First result
// 4. Before second await
// 5. Caught error: Error occurred
// 6. End of function
// C. Async function completed with result: Done

The Event Loop and Await

Understanding how await interacts with JavaScript's event loop is crucial for mastering asynchronous programming.

// Demonstrating event loop interaction
async function eventLoopDemo() {
  console.log('1. Start');
  
  // This timer will be scheduled by the event loop
  setTimeout(() => {
    console.log('3. Timer callback executed');
  }, 0);
  
  // The await causes the function to suspend
  // This gives the event loop a chance to process the timer
  await Promise.resolve();
  console.log('2. After await');
  
  // Another microtask
  await Promise.resolve();
  console.log('4. After second await');
}

eventLoopDemo();

// Output:
// 1. Start
// 2. After await
// 3. Timer callback executed
// 4. After second await

In the example above, even though the timer is set to 0ms, the timer callback executes after "After await" because:

  1. The await suspends the async function and yields to the event loop.
  2. When the Promise resolves, a microtask is queued to resume the function.
  3. Microtasks have priority over timer callbacks (macrotasks) in the event loop.
  4. The function resumes and logs "After await" before the timer callback runs.

Error Handling with Await

One of the most significant advantages of async/await is the return to familiar try/catch error handling for asynchronous code.

How Errors Propagate with Await

When a Promise is rejected, the await expression throws an exception that can be caught with a try/catch block.

try { const data = await riskyOperation(); } catch (error) { console.error('Operation failed:', error); }
// Basic error handling with try/catch
async function fetchAndProcessData(url) {
  try {
    const response = await fetch(url);
    
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    
    const data = await response.json();
    return processData(data);
  } catch (error) {
    console.error('Data processing failed:', error);
    
    // Re-throw specific errors, handle others
    if (error.message.includes('HTTP error')) {
      throw error; // Re-throw HTTP errors
    }
    
    // Return fallback for other errors
    return { error: true, message: error.message };
  }
}

// Multiple await statements in a single try/catch
async function updateUserProfile(userId, profileData) {
  try {
    // All these await operations are protected by the same try/catch
    const currentUser = await fetchUser(userId);
    const validatedData = await validateProfileData(profileData);
    const updatedUser = await updateUserInDatabase(userId, validatedData);
    await notifyUserOfUpdate(userId);
    
    return updatedUser;
  } catch (error) {
    // Any error in the chain will be caught here
    logError('Profile update failed', error);
    throw new Error(`Could not update profile: ${error.message}`);
  }
}

Fine-Grained Error Handling

For more precise error handling, you can use nested try/catch blocks to handle errors from specific operations differently.

// Fine-grained error handling with nested try/catch
async function importUserData(userId, dataFile) {
  try {
    // Fetch the user first
    const user = await fetchUser(userId);
    
    try {
      // Parse the data file (might fail)
      const userData = await parseDataFile(dataFile);
      
      try {
        // Import data to database (might fail)
        const result = await importToDatabase(user, userData);
        return result;
      } catch (importError) {
        // Only catches errors from importToDatabase
        console.error('Database import failed:', importError);
        await notifyAdmin('import_failure', { user, error: importError });
        throw new Error(`Import failed: ${importError.message}`);
      }
    } catch (parseError) {
      // Only catches errors from parseDataFile
      console.error('Data parsing failed:', parseError);
      return {
        success: false,
        phase: 'parsing',
        error: parseError.message
      };
    }
  } catch (userError) {
    // Only catches errors from fetchUser
    console.error('User retrieval failed:', userError);
    return {
      success: false,
      phase: 'user_fetch',
      error: userError.message
    };
  }
}

While nested try/catch blocks provide fine-grained control, they can make code harder to read and maintain. Use them judiciously.

Advanced Error Handling Patterns

The Finally Block

The finally block executes after the try/catch completes, regardless of whether an error was thrown. This is ideal for cleanup operations.

// Using finally for cleanup
async function processFileUpload(file) {
  const tempFilePath = await createTempFile();
  
  try {
    await saveFileToTemp(file, tempFilePath);
    const processedData = await processFile(tempFilePath);
    await saveToDatabase(processedData);
    
    return { success: true, data: processedData };
  } catch (error) {
    console.error('File processing failed:', error);
    return { success: false, error: error.message };
  } finally {
    // This runs regardless of success or failure
    try {
      await deleteTempFile(tempFilePath);
      console.log('Temp file deleted');
    } catch (cleanupError) {
      // Handle cleanup errors separately to avoid masking the main error
      console.warn('Failed to delete temp file:', cleanupError);
    }
  }
}

Error Types and Custom Errors

Use error types and custom error classes to provide more context and enable selective error handling.

// Custom error classes for better error handling
class APIError extends Error {
  constructor(message, statusCode, endpoint) {
    super(message);
    this.name = 'APIError';
    this.statusCode = statusCode;
    this.endpoint = endpoint;
    this.date = new Date();
  }
  
  isClientError() {
    return this.statusCode >= 400 && this.statusCode < 500;
  }
  
  isServerError() {
    return this.statusCode >= 500;
  }
}

class ValidationError extends Error {
  constructor(message, fieldErrors = {}) {
    super(message);
    this.name = 'ValidationError';
    this.fieldErrors = fieldErrors;
  }
}

// Using custom errors in async functions
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      throw new APIError(
        `Failed to fetch user ${userId}`,
        response.status,
        `/api/users/${userId}`
      );
    }
    
    const userData = await response.json();
    
    // Validate the response
    const errors = validateUserData(userData);
    if (Object.keys(errors).length > 0) {
      throw new ValidationError('Invalid user data', errors);
    }
    
    return userData;
  } catch (error) {
    if (error instanceof APIError) {
      if (error.isClientError()) {
        // Handle client errors (like 404, 403)
        console.error(`API client error: ${error.message}`);
      } else if (error.isServerError()) {
        // Handle server errors (like 500)
        console.error(`API server error: ${error.message}`);
        await reportServerError(error);
      }
    } else if (error instanceof ValidationError) {
      // Handle validation errors
      console.error('Validation failed:', error.fieldErrors);
    } else {
      // Handle unexpected errors
      console.error('Unexpected error:', error);
    }
    
    throw error; // Re-throw for upstream handling
  }
}

Retry Pattern with Await

Implement retry logic for operations that might fail temporarily, such as network requests.

// Retry pattern with exponential backoff
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let retries = 0;
  
  while (true) {
    try {
      const response = await fetch(url, options);
      
      if (!response.ok) {
        // For 5xx errors, we'll retry
        if (response.status >= 500 && retries < maxRetries) {
          throw new Error(`Server error: ${response.status}`);
        }
        
        // For other status codes, fail immediately
        throw new Error(`Request failed with status: ${response.status}`);
      }
      
      return await response.json();
    } catch (error) {
      retries++;
      
      if (retries > maxRetries) {
        console.error(`Failed after ${maxRetries} retries:`, error);
        throw error;
      }
      
      // Calculate exponential backoff with jitter
      const delay = Math.min(1000 * Math.pow(2, retries), 10000) 
                  + Math.floor(Math.random() * 1000);
      
      console.warn(`Attempt ${retries} failed, retrying in ${delay}ms...`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

Circuit Breaker Pattern

Implement a circuit breaker to prevent cascading failures when a service is experiencing issues.

// Circuit breaker pattern
class CircuitBreaker {
  constructor(action, options = {}) {
    this.action = action;
    this.failureThreshold = options.failureThreshold || 3;
    this.resetTimeout = options.resetTimeout || 30000;
    this.failureCount = 0;
    this.isOpen = false;
    this.lastFailureTime = null;
  }
  
  async execute(...args) {
    if (this.isOpen) {
      // Check if circuit should be reset (half-open)
      if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
        this.isOpen = false;
      } else {
        throw new Error('Circuit is open - service unavailable');
      }
    }
    
    try {
      const result = await this.action(...args);
      this.failureCount = 0; // Reset on success
      return result;
    } catch (error) {
      this.failureCount++;
      this.lastFailureTime = Date.now();
      
      if (this.failureCount >= this.failureThreshold) {
        this.isOpen = true;
        console.error(`Circuit opened after ${this.failureCount} failures`);
      }
      
      throw error;
    }
  }
}

// Using the circuit breaker
const serviceCall = new CircuitBreaker(
  async (endpoint) => {
    const response = await fetch(`https://api.example.com/${endpoint}`);
    if (!response.ok) throw new Error(`Service error: ${response.status}`);
    return response.json();
  },
  { failureThreshold: 5, resetTimeout: 60000 }
);

async function getUserData(userId) {
  try {
    return await serviceCall.execute(`users/${userId}`);
  } catch (error) {
    if (error.message.includes('Circuit is open')) {
      return getFallbackUserData(userId);
    }
    throw error;
  }
}

Common Await Pitfalls and Mistakes

Even experienced developers can make mistakes with async/await. Here are some common pitfalls to avoid.

Forgetting to Await

One of the most common mistakes is forgetting to use the await keyword with a Promise.

// INCORRECT: Forgetting to await
async function updateUser(userId, data) {
  try {
    // No await here! This just returns a Promise without waiting for it
    const result = saveToDatabase(userId, data);
    
    // This runs immediately, before the Promise resolves!
    console.log('User updated successfully');
    return { success: true };
  } catch (error) {
    // This catch block will never handle saveToDatabase errors
    console.error('Failed to update user:', error);
    return { success: false, error: error.message };
  }
}

// CORRECT: Using await properly
async function updateUser(userId, data) {
  try {
    // Wait for the Promise to resolve
    const result = await saveToDatabase(userId, data);
    
    // This runs after the Promise resolves
    console.log('User updated successfully');
    return { success: true };
  } catch (error) {
    console.error('Failed to update user:', error);
    return { success: false, error: error.message };
  }
}

Sequential vs. Concurrent Await

Using await sequentially when operations could run concurrently can hurt performance.

// INEFFICIENT: Sequential awaits when operations could run concurrently
async function loadUserDashboard(userId) {
  // Each operation waits for the previous one to complete
  const userData = await fetchUserProfile(userId);
  const userPosts = await fetchUserPosts(userId);
  const userAnalytics = await fetchUserAnalytics(userId);
  
  return {
    user: userData,
    posts: userPosts,
    analytics: userAnalytics
  };
}

// BETTER: Concurrent operations with Promise.all
async function loadUserDashboard(userId) {
  // Start all three operations simultaneously
  const [userData, userPosts, userAnalytics] = await Promise.all([
    fetchUserProfile(userId),
    fetchUserPosts(userId),
    fetchUserAnalytics(userId)
  ]);
  
  return {
    user: userData,
    posts: userPosts,
    analytics: userAnalytics
  };
}

Swallowing Errors

Catching errors without proper handling or logging can make debugging difficult.

// BAD: Swallowing errors without proper handling
async function processPayment(orderId) {
  try {
    const result = await chargeCustomer(orderId);
    return result;
  } catch (error) {
    // Error is caught but not logged or handled properly
    return { success: false }; // Details of the error are lost!
  }
}

// BETTER: Proper error handling
async function processPayment(orderId) {
  try {
    const result = await chargeCustomer(orderId);
    return result;
  } catch (error) {
    // Log the error with context
    console.error(`Payment processing failed for order ${orderId}:`, error);
    
    // Include error details in the response
    return { 
      success: false, 
      error: error.message,
      errorCode: error.code || 'UNKNOWN_ERROR',
      orderId: orderId
    };
  }
}

Uncaught Promise Rejections

Always handle potential rejections from async functions to avoid uncaught exceptions.

// BAD: Not handling potential rejection
const button = document.getElementById('submit-button');

button.addEventListener('click', async function() {
  // If this throws, the error won't be caught anywhere!
  const result = await submitForm();
  showSuccess(result);
});

// BETTER: Always include try/catch in async event handlers
button.addEventListener('click', async function() {
  try {
    const result = await submitForm();
    showSuccess(result);
  } catch (error) {
    console.error('Form submission failed:', error);
    showError(error.message);
  }
});

Async Callback Functions

Be careful when using async callbacks with functions that aren't designed for Promises.

// PROBLEMATIC: Array methods don't wait for async callbacks
async function processItems(items) {
  // forEach doesn't wait for async callbacks to complete!
  items.forEach(async (item) => {
    await processItem(item); // These run in parallel, but results are lost
  });
  
  console.log('All items processed'); // This runs immediately, not after processing!
}

// BETTER: Use a for...of loop with await
async function processItems(items) {
  for (const item of items) {
    await processItem(item); // Each iteration waits for processItem to complete
  }
  
  console.log('All items processed'); // This runs after all processing is done
}

// ALTERNATIVE: Use Promise.all if parallel processing is desired
async function processItemsConcurrently(items) {
  await Promise.all(items.map(item => processItem(item)));
  console.log('All items processed'); // This runs after all processing is done
}

Debugging Async/Await Code

Debugging asynchronous code can be challenging, but async/await makes it significantly easier compared to callbacks or raw Promises.

Setting Breakpoints

With async/await, you can set breakpoints at any line, including before and after await expressions.

async function fetchData() {
  // Set a breakpoint here to inspect before the fetch
  const response = await fetch('/api/data');
  
  // Set a breakpoint here to inspect the response
  const data = await response.json();
  
  // Set a breakpoint here to inspect the parsed data
  return processData(data);
}

Observing Promise States

Log the state of Promises at different stages to understand asynchronous flow.

async function debugPromises() {
  // Create a Promise
  const promise = new Promise(resolve => setTimeout(() => resolve('Done'), 1000));
  
  console.log('Initial promise state:', promise);
  // Output: Promise {}
  
  // Wait for it to resolve
  const result = await promise;
  
  console.log('After await, promise state:', promise);
  // Output: Promise {: "Done"}
  
  console.log('Result:', result);
  // Output: Done
}

Try/Catch for Debugging

Use try/catch blocks to catch and inspect errors during development.

// Debug helper function
async function debugAwait(promise, label = 'Promise') {
  try {
    console.log(`${label} - Starting`);
    const result = await promise;
    console.log(`${label} - Resolved:`, result);
    return result;
  } catch (error) {
    console.error(`${label} - Rejected:`, error);
    throw error; // Re-throw to maintain normal flow
  }
}

// Using the debug helper
async function fetchUserData(userId) {
  try {
    const userPromise = fetch(`/api/users/${userId}`);
    const response = await debugAwait(userPromise, 'User Fetch');
    
    const dataPromise = response.json();
    const userData = await debugAwait(dataPromise, 'JSON Parse');
    
    return userData;
  } catch (error) {
    console.error('Operation failed:', error);
    return null;
  }
}

Async Stack Traces

Modern browsers and Node.js provide better stack traces for async functions, showing the async call chain.

// Modern browsers show the complete async stack trace
async function level3() {
  throw new Error('Something went wrong');
}

async function level2() {
  await level3();
}

async function level1() {
  await level2();
}

// This will show a stack trace through the async calls
level1().catch(error => {
  console.error('Error with stack trace:', error);
  // Error: Something went wrong
  //     at level3 (script.js:2)
  //     at async level2 (script.js:6)
  //     at async level1 (script.js:10)
});

Browser and Environment Support

The await operator is widely supported in modern environments, but considerations for older browsers may be necessary.

Browser Support

Node.js Support

Transpilation for Older Environments

For older environments, you can use tools like Babel to transpile async/await code to backwards-compatible alternatives.

// Configure Babel for async/await support
// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: {
        browsers: [
          'last 2 versions',
          'not ie <= 11',
          'not op_mini all'
        ],
        node: '10'
      }
    }]
  ],
  plugins: [
    '@babel/plugin-transform-runtime'
  ]
};

Polyfilling Promises

Since async/await relies on Promises, you may need to include a Promise polyfill for very old browsers.

// Using core-js for polyfills
// Installation: npm install core-js regenerator-runtime

// In your entry file:
import 'core-js/stable';
import 'regenerator-runtime/runtime';

Await in Different Contexts

Loops and Await

Different loop types have different behavior with async/await, especially regarding sequential vs. concurrent execution.

flowchart TD A["Iterating with Await"] --> B["Sequential"] A --> C["Concurrent"] B --> D["for...of + await
(waits for each iteration)"] B --> E["for + await
(waits for each iteration)"] B --> F["while + await
(waits for each iteration)"] C --> G["Promise.all + map
(all run in parallel)"] C --> H["for...of without await
in the loop body
(careful!)"] style A fill:#f9f,stroke:#333,stroke-width:2px style B fill:#bbdefb,stroke:#333,stroke-width:1px style C fill:#c8e6c9,stroke:#333,stroke-width:1px
// Sequential processing with for...of
async function processFilesSequentially(files) {
  const results = [];
  
  for (const file of files) {
    // Each iteration waits for processing to complete
    const result = await processFile(file);
    results.push(result);
  }
  
  return results;
}

// Concurrent processing with Promise.all
async function processFilesConcurrently(files) {
  // Start processing all files at once
  const promises = files.map(file => processFile(file));
  
  // Wait for all to complete
  const results = await Promise.all(promises);
  return results;
}

// Batched processing (controlled concurrency)
async function processFilesInBatches(files, batchSize = 3) {
  const results = [];
  
  // Process files in batches
  for (let i = 0; i < files.length; i += batchSize) {
    const batch = files.slice(i, i + batchSize);
    
    // Process current batch concurrently
    const batchResults = await Promise.all(batch.map(file => processFile(file)));
    results.push(...batchResults);
    
    console.log(`Completed batch ${i / batchSize + 1}`);
  }
  
  return results;
}

Conditional Await

Sometimes you need to conditionally await a value based on runtime conditions.

// Conditional await based on value type
async function processValue(value) {
  let result;
  
  if (value instanceof Promise) {
    // Only await if it's a Promise
    result = await value;
  } else {
    // Use directly if it's already a value
    result = value;
  }
  
  return result;
}

// Conditional await based on configuration
async function fetchData(endpoint, config) {
  // Create base request
  let request = fetch(endpoint);
  
  // Conditionally transform the response
  if (config.parseJson) {
    request = request.then(response => response.json());
  } else if (config.parseText) {
    request = request.then(response => response.text());
  }
  
  // Wait for the final result
  return await request;
}

Await in Class Methods

Class methods can be async, enabling clean integration of asynchronous operations in object-oriented code.

// Async methods in classes
class UserService {
  constructor(apiClient) {
    this.apiClient = apiClient;
    this.cache = new Map();
  }
  
  async getUser(userId) {
    // Check cache first
    if (this.cache.has(userId)) {
      return this.cache.get(userId);
    }
    
    try {
      const user = await this.apiClient.fetch(`/users/${userId}`);
      this.cache.set(userId, user);
      return user;
    } catch (error) {
      console.error(`Failed to fetch user ${userId}:`, error);
      throw new Error(`User retrieval failed: ${error.message}`);
    }
  }
  
  async updateUser(userId, data) {
    try {
      const result = await this.apiClient.update(`/users/${userId}`, data);
      
      // Update cache with the new data
      if (this.cache.has(userId)) {
        const cachedUser = this.cache.get(userId);
        this.cache.set(userId, { ...cachedUser, ...data });
      }
      
      return result;
    } catch (error) {
      console.error(`Failed to update user ${userId}:`, error);
      throw new Error(`User update failed: ${error.message}`);
    }
  }
  
  // Getter method can be async too
  async get userActivity(userId) {
    const user = await this.getUser(userId);
    return await this.apiClient.fetch(`/activities?userId=${userId}`);
  }
}

Await in Array Methods

Be careful with await in array method callbacks, as they don't automatically wait for asynchronous operations.

// CAUTION: Array methods don't wait for async callbacks!

// This doesn't work as expected - the method returns before async callbacks complete
const processItems = async (items) => {
  const results = items.map(async item => {
    const processed = await processItem(item);
    return processed;
  });
  
  // 'results' is an array of Promises, not resolved values!
  return results;
};

// Correct way: Wait for all promises with Promise.all
const processItems = async (items) => {
  const promises = items.map(async item => {
    const processed = await processItem(item);
    return processed;
  });
  
  // Wait for all promises to resolve
  const results = await Promise.all(promises);
  return results;
};

// Alternative with filter
const filterExpiredItems = async (items) => {
  const checkResults = await Promise.all(
    items.map(async item => {
      const isExpired = await checkItemExpiration(item);
      return { item, isExpired };
    })
  );
  
  // Filter based on the resolved values
  return checkResults
    .filter(result => !result.isExpired)
    .map(result => result.item);
};

Real-World Examples

Data Fetching in Web Applications

A common use case for await is fetching and processing data from APIs.

// React component with data fetching
async function fetchDashboardData(userId) {
  try {
    // Fetch user profile
    const userResponse = await fetch(`/api/users/${userId}`);
    if (!userResponse.ok) {
      throw new Error(`Failed to fetch user: ${userResponse.statusText}`);
    }
    const userData = await userResponse.json();
    
    // Fetch related data concurrently
    const [postsData, analyticsData] = await Promise.all([
      fetch(`/api/posts?userId=${userId}`)
        .then(res => res.ok ? res.json() : []),
      
      fetch(`/api/analytics?userId=${userId}`)
        .then(res => res.ok ? res.json() : { views: 0, engagement: 0 })
    ]);
    
    // Combine all data
    return {
      user: userData,
      posts: postsData,
      analytics: analyticsData,
      lastUpdated: new Date().toISOString()
    };
  } catch (error) {
    console.error('Dashboard data fetching failed:', error);
    
    // Return fallback data
    return {
      user: { id: userId, name: 'Unknown User' },
      posts: [],
      analytics: { views: 0, engagement: 0 },
      error: error.message,
      lastUpdated: new Date().toISOString()
    };
  }
}

Transaction Processing

Await is perfect for handling multi-step transactions that need to be processed in order.

// E-commerce order processing
async function processOrder(orderId) {
  // Start a database transaction
  const transaction = await db.beginTransaction();
  
  try {
    // 1. Get the order
    const order = await db.orders.findOne(
      { _id: orderId },
      { transaction }
    );
    
    if (!order) {
      throw new Error(`Order ${orderId} not found`);
    }
    
    // 2. Check inventory for all items
    for (const item of order.items) {
      const product = await db.products.findOne(
        { _id: item.productId },
        { transaction }
      );
      
      if (!product || product.stock < item.quantity) {
        throw new Error(`Insufficient stock for product ${item.productId}`);
      }
      
      // Update inventory
      await db.products.update(
        { _id: item.productId },
        { $inc: { stock: -item.quantity } },
        { transaction }
      );
    }
    
    // 3. Process payment
    const paymentResult = await paymentGateway.charge({
      amount: order.total,
      source: order.paymentMethod,
      description: `Order #${order.orderNumber}`
    });
    
    if (!paymentResult.success) {
      throw new Error(`Payment failed: ${paymentResult.error}`);
    }
    
    // 4. Update order status
    await db.orders.update(
      { _id: orderId },
      { 
        $set: { 
          status: 'paid',
          paymentId: paymentResult.id,
          processedAt: new Date()
        }
      },
      { transaction }
    );
    
    // 5. Commit the transaction
    await transaction.commit();
    
    // 6. Send confirmation
    await sendOrderConfirmation(order);
    
    return {
      success: true,
      orderId: orderId,
      paymentId: paymentResult.id
    };
  } catch (error) {
    // Roll back the transaction on any error
    await transaction.rollback();
    
    console.error('Order processing failed:', error);
    
    // Notify customer service for specific errors
    if (error.message.includes('Insufficient stock')) {
      await notifyInventoryIssue(order, error);
    } else if (error.message.includes('Payment failed')) {
      await notifyPaymentIssue(order, error);
    }
    
    return {
      success: false,
      orderId: orderId,
      error: error.message
    };
  }
}

Animation Sequences

Await can simplify complex animation sequences by making timing and sequencing more intuitive.

// Animation sequence with await
async function animateLoginSequence(formElement, successCallback) {
  // Helper function for timing
  const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
  
  try {
    // Validate form
    const isValid = validateForm(formElement);
    if (!isValid) {
      throw new Error('Form validation failed');
    }
    
    // 1. Disable the form and show loading
    formElement.classList.add('form-disabled');
    const loadingSpinner = document.getElementById('loading-spinner');
    loadingSpinner.style.display = 'block';
    
    // 2. Submit the form
    const formData = new FormData(formElement);
    const response = await fetch('/api/login', {
      method: 'POST',
      body: formData
    });
    
    if (!response.ok) {
      throw new Error(`Login failed: ${response.statusText}`);
    }
    
    const userData = await response.json();
    
    // 3. Hide loading, show success checkmark
    loadingSpinner.style.display = 'none';
    const successMark = document.getElementById('success-mark');
    successMark.style.display = 'block';
    successMark.classList.add('animate-in');
    
    // 4. Wait for animation to complete
    await wait(1000);
    
    // 5. Fade out the form
    formElement.style.transition = 'opacity 0.5s ease-out';
    formElement.style.opacity = '0';
    await wait(500);
    
    // 6. Replace with welcome message
    const welcomeMessage = document.getElementById('welcome-message');
    welcomeMessage.textContent = `Welcome, ${userData.name}!`;
    welcomeMessage.style.opacity = '0';
    welcomeMessage.style.display = 'block';
    
    // 7. Fade in welcome message
    welcomeMessage.style.transition = 'opacity 0.5s ease-in';
    welcomeMessage.style.opacity = '1';
    await wait(500);
    
    // 8. Complete the sequence
    successCallback(userData);
  } catch (error) {
    // Handle animation errors
    const errorMessage = document.getElementById('error-message');
    errorMessage.textContent = error.message;
    errorMessage.style.display = 'block';
    
    // Reset form
    formElement.classList.remove('form-disabled');
    document.getElementById('loading-spinner').style.display = 'none';
    
    // Shake the form to indicate error
    formElement.classList.add('shake-animation');
    await wait(500);
    formElement.classList.remove('shake-animation');
  }
}

Practice Exercises

Exercise 1: Error Handling with Await

Write an async function that attempts to fetch data from multiple fallback sources, handling errors appropriately.

Exercise 2: Concurrent vs. Sequential Processing

Implement two versions of a function that processes an array of files: one that processes them sequentially and another that processes them concurrently. Compare the performance.

Exercise 3: Custom Error Handling

Create a custom error class for API errors and implement an async function that uses it to provide detailed error information.

Exercise 4: Retry Pattern

Implement a retry mechanism for an async function that might fail temporarily. Include exponential backoff and a maximum retry count.

Summary

The await keyword is a powerful tool for managing asynchronous operations in JavaScript:

By mastering the await keyword and its error handling capabilities, you can write asynchronous code that is cleaner, more maintainable, and more robust, handling even complex error scenarios with grace and precision.

Further Reading