Back to posts

Async Patterns in Modern JavaScript

From callbacks to async/await—understanding JavaScript's evolution and mastering concurrent operations.

javascriptasyncpatterns

JavaScript's approach to asynchronous programming has evolved dramatically. Let's trace that journey and learn the patterns that make modern async code readable and maintainable.

The Callback Era

In the beginning, there were callbacks:

javascript
function fetchUser(id, callback) {
  setTimeout(() => {
    callback(null, { id, name: 'Alice' });
  }, 100);
}

function fetchPosts(userId, callback) {
  setTimeout(() => {
    callback(null, [
      { id: 1, title: 'Hello World' },
      { id: 2, title: 'Async is Hard' }
    ]);
  }, 100);
}

// The pyramid of doom
fetchUser(1, (err, user) => {
  if (err) return console.error(err);
  fetchPosts(user.id, (err, posts) => {
    if (err) return console.error(err);
    console.log(user.name, posts);
  });
});

This works, but nesting quickly becomes unmanageable. Error handling is scattered and easy to forget.

Promises: A Better Way

Promises flatten the callback pyramid:

javascript
function fetchUser(id) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id, name: 'Alice' }), 100);
  });
}

function fetchPosts(userId) {
  return new Promise((resolve) => {
    setTimeout(() => resolve([
      { id: 1, title: 'Hello World' },
      { id: 2, title: 'Async is Hard' }
    ]), 100);
  });
}

// Chained promises
fetchUser(1)
  .then(user => fetchPosts(user.id).then(posts => ({ user, posts })))
  .then(({ user, posts }) => console.log(user.name, posts))
  .catch(err => console.error(err));

Better, but still not as readable as synchronous code.

Async/Await: The Modern Standard

Async/await makes asynchronous code read like synchronous code:

javascript
async function getUserWithPosts(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error('Failed to fetch:', error);
    throw error;
  }
}

// Usage
const data = await getUserWithPosts(1);
console.log(data.user.name, data.posts);

The mental model is straightforward: each await pauses execution until the promise resolves.

Advanced Patterns

Parallel Execution

Don't await sequentially when you can run in parallel:

javascript
// ❌ Sequential - slow
async function fetchAllSequential(ids) {
  const results = [];
  for (const id of ids) {
    results.push(await fetchUser(id));
  }
  return results;
}

// ✅ Parallel - fast
async function fetchAllParallel(ids) {
  return Promise.all(ids.map(id => fetchUser(id)));
}

With 10 items at 100ms each, sequential takes 1000ms while parallel takes ~100ms.

Controlled Concurrency

Sometimes you need to limit parallelism:

javascript
async function mapWithConcurrency<T, R>(
  items: T[],
  fn: (item: T) => Promise<R>,
  concurrency: number
): Promise<R[]> {
  const results: R[] = [];
  const executing: Promise<void>[] = [];
  
  for (const item of items) {
    const promise = fn(item).then(result => {
      results.push(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;
}

// Process 100 items, 5 at a time
await mapWithConcurrency(items, processItem, 5);

This prevents overwhelming APIs or databases with too many concurrent requests.

Error Handling Patterns

Handle errors gracefully with wrapper functions:

javascript
// Tuple-based error handling (Go-style)
async function safeAsync<T>(
  promise: Promise<T>
): Promise<[T, null] | [null, Error]> {
  try {
    const result = await promise;
    return [result, null];
  } catch (error) {
    return [null, error as Error];
  }
}

// Usage
const [user, error] = await safeAsync(fetchUser(1));
if (error) {
  console.error('Failed:', error.message);
  return;
}
console.log(user.name);

This pattern makes error handling explicit without try/catch blocks everywhere.

Retry Logic

Network requests fail. Build in resilience:

javascript
async function retry<T>(
  fn: () => Promise<T>,
  { attempts = 3, delay = 1000, backoff = 2 } = {}
): Promise<T> {
  let lastError: Error;
  
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
      if (i < attempts - 1) {
        await new Promise(r => setTimeout(r, delay * Math.pow(backoff, i)));
      }
    }
  }
  
  throw lastError!;
}

// Usage
const user = await retry(() => fetchUser(1), {
  attempts: 3,
  delay: 1000,
  backoff: 2
});

Exponential backoff prevents thundering herds when services recover.

Best Practices

  1. Always handle errors — Unhandled promise rejections crash Node.js
  2. Prefer async/await — It's more readable than raw promises
  3. Parallelize when possible — Sequential awaits are a code smell
  4. Set timeouts — Don't wait forever for unresponsive services
  5. Use AbortController — Allow cancellation of long-running operations

Conclusion

Modern JavaScript gives us powerful tools for async programming. The key is understanding when to use each pattern:

  • Sequential when order matters
  • Parallel when operations are independent
  • Controlled concurrency when resources are limited
  • Retry when failures are transient

Master these patterns and async code becomes a joy rather than a headache.