Labs ICT
Pro Login

Async / Await

Async/await is modern JavaScript syntax that makes asynchronous code look and behave more like synchronous code. Built on top of Promises, async/await provides a cleaner way to handle asynchronous operations, making code more readable and maintainable.

In this tutorial, we'll explore async/await syntax, error handling, patterns, and best practices for writing effective asynchronous JavaScript code.

Understanding Async/Await

Async/await consists of two keywords: async for declaring asynchronous functions and await for pausing execution until a Promise resolves.

Basic Syntax

// async function declaration
async function fetchData() {
  return 'Data fetched';
}

// async function always returns a Promise
const result = fetchData();
console.log(result); // Promise<resolved>

// await keyword (only in async functions)
async function example() {
  const data = await fetchData(); // Wait for Promise to resolve
  console.log(data); // "Data fetched"
}

example();

Comparison with Promises

// Promise-based approach
function fetchUserData() {
  return fetch('/api/user')
    .then(response => response.json())
    .then(user => {
      console.log('User:', user);
      return user.id;
    })
    .then(userId => {
      return fetch(`/api/posts/${userId}`);
    })
    .then(response => response.json())
    .then(posts => {
      console.log('Posts:', posts);
      return posts;
    })
    .catch(error => {
      console.error('Error:', error);
    });
}

// async/await approach
async function fetchUserDataAsync() {
  try {
    const response = await fetch('/api/user');
    const user = await response.json();
    console.log('User:', user);
    
    const postsResponse = await fetch(`/api/posts/${user.id}`);
    const posts = await postsResponse.json();
    console.log('Posts:', posts);
    
    return posts;
  } catch (error) {
    console.error('Error:', error);
  }
}

Arrow Functions with async

// async arrow function
const getData = async () => {
  const response = await fetch('/api/data');
  return response.json();
};

// async arrow function with parameters
const getUserById = async (id) => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
};

// Immediately invoked async function
(async () => {
  const data = await getData();
  console.log(data);
})();

Error Handling

Proper error handling is crucial in async/await code using try/catch blocks.

Try/Catch Blocks

// Basic error handling
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Failed to fetch data:', error);
    return null; // Return fallback value
  }
}

// Multiple await operations with error handling
async function processUserData(userId) {
  try {
    const user = await fetchUser(userId);
    const profile = await fetchProfile(user.profileId);
    const settings = await fetchSettings(user.id);
    
    return {
      user,
      profile,
      settings
    };
  } catch (error) {
    console.error('Error processing user data:', error);
    throw error; // Re-throw if you want caller to handle
  }
}

Granular Error Handling

// Handle different errors differently
async function fetchUserData(userId) {
  try {
    const user = await fetchUser(userId);
    return user;
  } catch (error) {
    if (error instanceof NetworkError) {
      console.log('Network error, retrying...');
      return fetchUserData(userId); // Retry
    } else if (error instanceof AuthenticationError) {
      console.log('Authentication failed, redirecting to login');
      redirectToLogin();
      return null;
    } else {
      console.error('Unexpected error:', error);
      throw error;
    }
  }
}

// Try/catch for individual operations
async function processMultipleItems(items) {
  const results = [];
  
  for (const item of items) {
    try {
      const result = await processItem(item);
      results.push(result);
    } catch (error) {
      console.error(`Error processing item ${item.id}:`, error);
      results.push({ id: item.id, error: error.message });
    }
  }
  
  return results;
}

Finally Block

// Finally block for cleanup
async function processWithCleanup() {
  let connection;
  try {
    connection = await createDatabaseConnection();
    const data = await fetchData(connection);
    const processed = await processData(data);
    return processed;
  } catch (error) {
    console.error('Processing failed:', error);
    throw error;
  } finally {
    if (connection) {
      await connection.close();
      console.log('Connection closed');
    }
  }
}

// Utility for cleanup
async function withCleanup(resource, callback) {
  try {
    return await callback(resource);
  } finally {
    await resource.cleanup();
  }
}

Parallel Execution

Running multiple async operations in parallel for better performance.

Promise.all

// Parallel execution with Promise.all
async function fetchUserDataAndPosts(userId) {
  try {
    const [user, posts, comments] = await Promise.all([
      fetchUser(userId),
      fetchUserPosts(userId),
      fetchUserComments(userId)
    ]);
    
    return { user, posts, comments };
  } catch (error) {
    console.error('Failed to fetch user data:', error);
    throw error;
  }
}

// Parallel execution with mapping
async function fetchMultipleUsers(userIds) {
  const userPromises = userIds.map(id => fetchUser(id));
  const users = await Promise.all(userPromises);
  return users;
}

// Handle partial failures
async function fetchWithFallbacks(userIds) {
  const userPromises = userIds.map(async (id) => {
    try {
      return await fetchUser(id);
    } catch (error) {
      console.error(`Failed to fetch user ${id}:`, error);
      return { id, error: error.message };
    }
  });
  
  return await Promise.all(userPromises);
}

Promise.allSettled

// Handle all results, even failures
async function fetchAllUserStatuses(userIds) {
  const promises = userIds.map(id => fetchUserStatus(id));
  const results = await Promise.allSettled(promises);
  
  const successful = results
    .filter(result => result.status === 'fulfilled')
    .map(result => result.value);
    
  const failed = results
    .filter(result => result.status === 'rejected')
    .map(result => result.reason);
    
  return { successful, failed };
}

// Process results based on status
async function processBatch(items) {
  const promises = items.map(item => processItem(item));
  const results = await Promise.allSettled(promises);
  
  return results.map((result, index) => ({
    item: items[index],
    status: result.status,
    value: result.status === 'fulfilled' ? result.value : null,
    error: result.status === 'rejected' ? result.reason : null
  }));
}

Promise.race

// Race between multiple operations
async function fetchWithTimeout(url, timeout = 5000) {
  const fetchPromise = fetch(url);
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('Request timeout')), timeout);
  });
  
  try {
    const response = await Promise.race([fetchPromise, timeoutPromise]);
    return response;
  } catch (error) {
    if (error.message === 'Request timeout') {
      console.log('Request timed out');
    }
    throw error;
  }
}

// Race multiple sources
async function fetchFromMultipleSources(sources) {
  const promises = sources.map(source => fetch(source));
  try {
    const response = await Promise.race(promises);
    return response;
  } catch (error) {
    console.error('All sources failed:', error);
    throw error;
  }
}

Async Patterns

Common patterns and techniques for working with async/await.

Sequential Processing

// Process items sequentially
async function processSequentially(items) {
  const results = [];
  
  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
  }
  
  return results;
}

// Process with rate limiting
async function processWithRateLimit(items, delay = 100) {
  const results = [];
  
  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
    await new Promise(resolve => setTimeout(resolve, delay));
  }
  
  return results;
}

// Sequential processing with error recovery
async function processWithRetry(items, maxRetries = 3) {
  const results = [];
  
  for (const item of items) {
    let retries = 0;
    let result = null;
    let error = null;
    
    while (retries < maxRetries && !result) {
      try {
        result = await processItem(item);
      } catch (err) {
        error = err;
        retries++;
        if (retries < maxRetries) {
          await new Promise(resolve => setTimeout(resolve, 1000 * retries));
        }
      }
    }
    
    results.push(result || { error, retries });
  }
  
  return results;
}

Bounded Parallelism

// Process items with limited concurrency
async function processWithConcurrency(items, concurrency = 3) {
  const results = [];
  const executing = [];
  
  for (const item of items) {
    const promise = processItem(item).then(result => {
      results.push(result);
      return result;
    });
    
    executing.push(promise);
    
    if (executing.length >= concurrency) {
      await Promise.race(executing);
      executing.splice(executing.findIndex(p => p === promise), 1);
    }
  }
  
  await Promise.all(executing);
  return results;
}

// Worker pool pattern
class AsyncPool {
  constructor(concurrency, processor) {
    this.concurrency = concurrency;
    this.processor = processor;
    this.running = 0;
    this.queue = [];
  }
  
  async process(item) {
    return new Promise((resolve, reject) => {
      this.queue.push({ item, resolve, reject });
      this.processQueue();
    });
  }
  
  async processQueue() {
    if (this.running >= this.concurrency || this.queue.length === 0) {
      return;
    }
    
    this.running++;
    const { item, resolve, reject } = this.queue.shift();
    
    try {
      const result = await this.processor(item);
      resolve(result);
    } catch (error) {
      reject(error);
    } finally {
      this.running--;
      this.processQueue();
    }
  }
}

Caching Patterns

// Simple cache with async operations
class AsyncCache {
  constructor() {
    this.cache = new Map();
    this.pending = new Map();
  }
  
  async get(key, fetchFn) {
    // Return cached value if available
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }
    
    // Return pending promise if already fetching
    if (this.pending.has(key)) {
      return this.pending.get(key);
    }
    
    // Start fetching
    const promise = fetchFn(key);
    this.pending.set(key, promise);
    
    try {
      const result = await promise;
      this.cache.set(key, result);
      return result;
    } finally {
      this.pending.delete(key);
    }
  }
}

// Usage
const cache = new AsyncCache();

async function getUserData(userId) {
  return cache.get(userId, async (id) => {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  });
}

// TTL cache
class TTLCache {
  constructor(ttl = 60000) { // Default 1 minute
    this.cache = new Map();
    this.ttl = ttl;
  }
  
  async get(key, fetchFn) {
    const cached = this.cache.get(key);
    
    if (cached && Date.now() - cached.timestamp < this.ttl) {
      return cached.value;
    }
    
    const value = await fetchFn(key);
    this.cache.set(key, {
      value,
      timestamp: Date.now()
    });
    
    return value;
  }
}

Async Generators

Combining generators with async for powerful data streaming patterns.

Async Generator Functions

// Async generator function
async function* fetchPages(url) {
  let page = 1;
  
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    
    if (data.items.length === 0) break;
    
    yield data.items;
    page++;
  }
}

// Consume async generator
async function processAllPages(url) {
  const generator = fetchPages(url);
  
  for await (const items of generator) {
    console.log(`Processing ${items.length} items`);
    await processItems(items);
  }
}

// Async generator for pagination
async function* paginate(apiUrl, pageSize = 10) {
  let offset = 0;
  
  while (true) {
    const response = await fetch(`${apiUrl}?limit=${pageSize}&offset=${offset}`);
    const data = await response.json();
    
    if (data.items.length === 0) break;
    
    yield* data.items; // Yield each item individually
    offset += pageSize;
  }
}

Streaming Data

// Stream data from API
async function* streamData(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  
  let done = false;
  
  while (!done) {
    const { value, done: readerDone } = await reader.read();
    done = readerDone;
    
    if (value) {
      const chunk = decoder.decode(value, { stream: true });
      yield chunk;
    }
  }
}

// Process streaming data
async function processStream(url) {
  for await (const chunk of streamData(url)) {
    console.log('Received chunk:', chunk);
    await processChunk(chunk);
  }
}

// Rate-limited stream
async function* rateLimitedStream(generator, delay = 1000) {
  for await (const item of generator) {
    yield item;
    await new Promise(resolve => setTimeout(resolve, delay));
  }
}

Top-Level Await

Using await at the top level of modules (ES2022).

Module-Level Await

// In ES modules (not in scripts)
// config.mjs
export const config = await fetchConfig();
export const database = await connectDatabase(config);

// Use config and database in the module
export async function initializeApp() {
  await database.migrate();
  console.log('App initialized');
}

// Import in another module
import { config, database, initializeApp } from './config.mjs';

console.log('Config loaded:', config);
await initializeApp();

Dynamic Imports with Await

// Dynamic import with top-level await
const { default: lodash } = await import('lodash');
const utils = await import('./utils.js');

// Conditional module loading
const heavyModule = await import('./heavy-module.js');
export { heavyModule };

// Lazy loading
let heavyLibrary = null;

export async function getHeavyLibrary() {
  if (!heavyLibrary) {
    heavyLibrary = await import('./heavy-library.js');
  }
  return heavyLibrary;
}

Performance Considerations

Optimizing async/await code for better performance.

Avoid Unnecessary Await

// Bad: Sequential when parallel is possible
async function badExample() {
  const user = await fetchUser(1);
  const posts = await fetchUserPosts(1);
  const comments = await fetchUserComments(1);
  return { user, posts, comments };
}

// Good: Parallel execution
async function goodExample() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(1),
    fetchUserPosts(1),
    fetchUserComments(1)
  ]);
  return { user, posts, comments };
}

// Bad: Unnecessary await in return
async function badReturn() {
  const data = await fetchData();
  return data; // Unnecessary await
}

// Good: Direct return
async function goodReturn() {
  return await fetchData(); // or just return fetchData()
}

Memory Management

// Process large datasets in chunks
async function processLargeDataset(dataset, chunkSize = 1000) {
  for (let i = 0; i < dataset.length; i += chunkSize) {
    const chunk = dataset.slice(i, i + chunkSize);
    await processChunk(chunk);
    
    // Allow garbage collection
    if (i % (chunkSize * 10) === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// Stream processing to avoid loading all data
async function processStreamData(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    
    await processChunk(value);
    // Process immediately, don't accumulate
  }
}

Best Practices

Guidelines for writing clean and maintainable async/await code.

Error Handling

// Always handle errors
async function robustFetch(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    return await response.json();
  } catch (error) {
    console.error(`Failed to fetch ${url}:`, error);
    throw error; // Re-throw for caller to handle
  }
}

// Create reusable error handling utilities
const withErrorHandling = (fn) => {
  return async (...args) => {
    try {
      return await fn(...args);
    } catch (error) {
      console.error(`Error in ${fn.name}:`, error);
      throw error;
    }
  };
};

const safeFetch = withErrorHandling(fetchData);

Code Organization

// Separate async logic from business logic
async function fetchUserData(userId) {
  // Only data fetching logic
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
}

function processUserData(userData) {
  // Only business logic
  return {
    fullName: `${userData.firstName} ${userData.lastName}`,
    displayName: userData.firstName,
    age: calculateAge(userData.birthDate)
  };
}

// Combine in service layer
async function getUserProfile(userId) {
  const userData = await fetchUserData(userId);
  return processUserData(userData);
}

// Use dependency injection for testability
async function createUserService(apiClient) {
  return {
    async getUser(id) {
      return apiClient.get(`/users/${id}`);
    },
    
    async updateUser(id, data) {
      return apiClient.put(`/users/${id}`, data);
    }
  };
}

Testing Async Code

// Test async functions properly
describe('User Service', () => {
  it('should fetch user data', async () => {
    const userData = await userService.getUser(1);
    expect(userData).toBeDefined();
    expect(userData.id).toBe(1);
  });
  
  it('should handle errors', async () => {
    await expect(userService.getUser(999))
      .rejects.toThrow('User not found');
  });
  
  it('should timeout if request takes too long', async () => {
    await expect(
      Promise.race([
        userService.getUser(1),
        new Promise((_, reject) => 
          setTimeout(() => reject(new Error('Timeout')), 100)
        )
      ])
    ).rejects.toThrow('Timeout');
  });
});

Summary

Key Takeaways

  • async/await makes asynchronous code look synchronous
  • Always use try/catch for error handling
  • Use Promise.all for parallel execution
  • Avoid unnecessary sequential operations
  • Top-level await works in ES modules

Best Practices

  • Handle errors at appropriate levels
  • Use parallel execution when possible
  • Separate data fetching from business logic
  • Implement proper timeout handling
  • Test async code thoroughly

Common Pitfalls

  • Forgetting to handle Promise rejections
  • Using await in loops when parallel is better
  • Mixing callbacks and async/await
  • Not handling timeouts properly
  • Creating memory leaks with unresolved Promises