DEV Community

Alex Chen
Alex Chen

Posted on

Async/Await in JavaScript: From Callbacks to Clean Code (2026)

Async/Await in JavaScript: From Callbacks to Clean Code (2026)

Async JavaScript has come a long way. Here's the complete picture — from callbacks to async/await and everything in between.

The Evolution

// ERA 1: Callbacks (2010-ish) — The "Callback Hell"
getData(function(a) {
  getMoreData(a, function(b) {
    getEvenMoreData(b, function(c) {
      getFinalData(c, function(finalData) {
        console.log(finalData);
      });
    });
  });
});
// → Pyramid of doom → Hard to read → Error handling is a nightmare

// ERA 2: Promises (2015) — Chainable, but still verbose
getData()
  .then(a => getMoreData(a))
  .then(b => getEvenMoreData(b))
  .then(c => getFinalData(c))
  .then(finalData => console.log(finalData))
  .catch(err => console.error(err));
// → Better! Linear flow. But still lots of .then() chains.

// ERA 3: Async/Await (2017+) — Looks like synchronous code!
async function processData() {
  const a = await getData();
  const b = await getMoreData(a);
  const c = await getEvenMoreData(b);
  const finalData = await getFinalData(c);
  console.log(finalData); // So clean!
}
// → Read top to bottom. Error handling with try/catch. Winner.
Enter fullscreen mode Exit fullscreen mode

Promises Deep Dive (You Need This Foundation)

Creating Promises

// Basic promise constructor
function fetchData(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.onload = () => {
      if (xhr.status === 200) resolve(xhr.response);
      else reject(new Error(`HTTP ${xhr.status}`));
    };
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.send();
  });
}

// Most APIs already return promises:
fetch(url).then(res => res.json());
fs.promises.readFile(path, 'utf8');
Enter fullscreen mode Exit fullscreen mode

Promise Methods You Should Know

// Promise.all — Run in parallel, wait for ALL
const [users, posts, comments] = await Promise.all([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/posts').then(r => r.json()),
  fetch('/api/comments').then(r => r.json()),
]);
// ALL must succeed. One fails = all fail.

// Promise.allSettled — Run all, get results regardless of success/failure
const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/posts'), // Maybe this one fails
  fetch('/api/comments'),
]);
results.forEach(r => {
  if (r.status === 'fulfilled') console.log(r.value);
  else console.error('Failed:', r.reason);
});

// Promise.race — First to finish wins (success or failure)
const result = await Promise.race([
  fetch('/api/fast-server'),
  fetchWithTimeout('/api/slow-server', 3000), // Custom timeout wrapper
]);

// Promise.any — First SUCCESSFUL result wins (ignores failures until all fail)
const data = await Promise.any([
  fetchFromPrimary(url),
  fetchFromBackup(url),
  fetchFromCache(url), // At least one needs to succeed
});

// Common pattern: Timeout wrapper
function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Timeout')), ms)
    ),
  ]);
}

await withTimeout(fetch('/api/heavy'), 5000); // Throws after 5s
Enter fullscreen mode Exit fullscreen mode

Async/Await Complete Guide

Syntax Basics

// async ALWAYS returns a Promise
async function hello() {
  return 'Hello!'; // Implicitly wrapped in Promise.resolve()
}
hello().then(console.log); // "Hello!"

// await pauses execution INSIDE async functions
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`); // Pauses here
  const user = await response.json();                  // Then resumes
  return user;
}

// Can use in arrow functions too
const getUser = async (id) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
};
Enter fullscreen mode Exit fullscreen mode

Parallel vs Sequential (Performance!)

// ❌ SLOW: Sequential awaits (each waits for previous)
async function getAllDataSlow() {
  const users = await fetch('/api/users');     // Wait 100ms
  const posts = await fetch('/api/posts');     // Wait another 150ms
  const comments = await fetch('/api/comments');// Wait another 80ms
  // Total: ~330ms
}

// ✅ FAST: Independent calls run in parallel
async function getAllDataFast() {
  const [usersRes, postsRes, commentsRes] = await Promise.all([
    fetch('/api/users'),     // All start simultaneously
    fetch('/api/posts'),
    fetch('/api/comments'),
  ]);                        // Total: ~150ms (slowest one)

  const [users, posts, comments] = await Promise.all([
    usersRes.json(),
    postsRes.json(),
    commentsRes.json(),
  ]);

  return { users, posts, comments };
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

// Method 1: try/catch (most common)
async function safeFetch(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (err) {
    console.error(`Failed to fetch ${url}:`, err.message);
    return null; // Or rethrow, or return fallback value
  }
}

// Method 2: Higher-order error handler
function withErrorHandler(fn) {
  return (...args) => fn(...args).catch(err => {
    console.error(`[Error in ${fn.name}]:`, err);
    return null; // Prevent unhandled rejection
  });
}

const safeGetUser = withErrorHandler(getUser);

// Method 3: Go pattern (explicit error handling)
async function loadData() {
  let [err, data] = await to(promiseHere());
  if (err) { /* handle */ }
  // use data
}

// Helper for Go-style error handling
function to(promise) {
  return promise.then(data => [null, data], err => [err, null]);
}
Enter fullscreen mode Exit fullscreen mode

Looping with Async/Await

// ❌ WRONG: forEach ignores async/await
urls.forEach(async url => {
  const data = await fetch(url); // Fires but doesn't await!
}); // Moves on immediately, doesn't wait for fetches

// ✅ for...of loop (sequential, each awaits)
for (const url of urls) {
  const data = await fetch(url); // Waits for each one
  process(data);
}

// ✅ for...of + Promise.all inside (parallel batches)
const batchSize = 5;
for (let i = 0; i < urls.length; i += batchSize) {
  const batch = urls.slice(i, i + batchSize);
  const results = await Promise.all(batch.map(url => fetch(url)));
  processBatch(results);
}

// ✅ map + Promise.all (fully parallel)
const results = await Promise.all(urls.map(async url => {
  const res = await fetch(url);
  return res.json();
}));
Enter fullscreen mode Exit fullscreen mode

Async Iterators & Generators

// Generator that yields items asynchronously
async function* paginate(endpoint, pageSize = 50) {
  let page = 1;
  while (true) {
    const response = await fetch(`${endpoint}?page=${page}&size=${pageSize}`);
    const data = await response.json();

    if (data.items.length === 0) break; // No more pages

    yield* data.items; // Yield each item
    page++;
  }
}

// Usage: consume with for-await-of
for await (const item of paginate('/api/users')) {
  console.log(item.name);
  // Processes one at a time, memory-efficient for huge datasets
}

// Or collect into array
const allUsers = [];
for await (const user of paginate('/api/users')) {
  allUsers.push(user);
}
Enter fullscreen mode Exit fullscreen mode

Real-World Patterns

Retry Logic

async function retry(fn, options = {}) {
  const {
    maxAttempts = 3,
    delayMs = 1000,
    backoff = 2,           // Exponential backoff multiplier
    shouldRetry = (e) => true, // Conditionally retry based on error
  } = options;

  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;

      if (attempt === maxAttempts || !shouldRetry(err)) {
        throw err; // Final attempt or non-retryable error
      }

      const waitTime = delayMs * Math.pow(backoff, attempt - 1);
      console.warn(`Attempt ${attempt}/${maxAttempts} failed. Retrying in ${waitTime}ms...`);
      await sleep(waitTime);
    }
  }
}

function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

// Usage
const data = await retry(() => fetch('https://flaky-api.com/data').then(r => r.json()), {
  maxAttempts: 5,
  delayMs: 1000,
  shouldRetry: (err) => err.message.includes('529') || err.message.includes('timeout'),
});
Enter fullscreen mode Exit fullscreen mode

Request Caching / Deduplication

class RequestCache {
  #cache = new Map();

  async get(key, fetcher, ttlMs = 60_000) {
    const cached = this.#cache.get(key);

    if (cached && Date.now() - cached.timestamp < ttlMs) {
      return cached.data; // Cache hit!
    }

    // In-flight deduplication: if request is already running, reuse it
    if (cached?.inflight) {
      return cached.inflight; // Return same promise
    }

    const inflight = fetcher().then(data => {
      this.#cache.set(key, { data, timestamp: Date.now() });
      return data;
    }).finally(() => {
      // Clear inflight flag when done
      const entry = this.#cache.get(key);
      if (entry?.inflight === inflight) entry.inflight = null;
    });

    this.#cache.set(key, { inflight, timestamp: Date.now() });
    return inflight;
  }

  clear(key) { this.#cache.delete(key); }
  clearAll() { this.#cache.clear(); }
}

const apiCache = new RequestCache();

// Multiple components calling same endpoint simultaneously
// → Only ONE actual HTTP request, others share the promise
const user1 = await apiCache.get('user:123', () => fetchUser(123));
const user2 = await apiCache.get('user:123', () => fetchUser(123)); // Cached!
Enter fullscreen mode Exit fullscreen mode

Race Conditions & Mutex

// Problem: Double-click triggers two writes
async function updateProfile(data) {
  await db.update(user.id, data); // Click once → OK
  // Click twice → Two updates, potential conflict!
}

// Solution: Mutex (mutual exclusion)
class Mutex {
  #locked = false;
  #queue = [];

  async acquire() {
    while (this.#locked) {
      await new Promise(resolve => this.#queue.push(resolve));
    }
    this.#locked = true;
  }

  release() {
    this.#locked = false;
    if (this.#queue.length > 0) {
      this.#queue.shift()(); // Wake up next waiter
    }
  }
}

const profileMutex = new Mutex();

async function updateProfileSafe(data) {
  await profileMutex.acquire();
  try {
    await db.update(user.id, data);
  } finally {
    profileMutex.release();
  }
}
// Now rapid clicks queue up instead of racing.
Enter fullscreen mode Exit fullscreen mode

Top Mistakes to Avoid

// ❌ Forgetting await in loops
async function processItems(items) {
  items.forEach(async item => {
    await process(item); // ❌ forEach doesn't await!
  }); // Returns immediately, doesn't wait
}

// ✅ Use for...of
for (const item of items) {
  await process(item); // ✅ Each one completes before next
}

// ❌ Unhandled promise rejection
async function risky() {
  throw new Error('Oops');
}
risky(); // No .catch(), no await → UnhandledRejection!

// ✅ Always handle or delegate
risky().catch(err => console.error(err)); // Handled
// OR ensure caller awaits it

// ❌ Using await inside non-async callback
['a', 'b', 'c'].forEach(item => {
  await process(item); // SyntaxError! Not in async function
});

// ✅ Wrap in async IIFE or use for...of
for (const item of ['a', 'b', 'c']) {
  await process(item); // Works fine
}

// ❌ Mixing callbacks and promises incorrectly
fs.readFile(file, (err, data) => {
  const result = await parse(data); // SyntaxError! Not async
});

// ✅ Use fs.promises (Promise-based API)
const data = await fs.promises.readFile(file, 'utf8');
const result = await parse(data);

// ❌ Blocking event loop with CPU-heavy work
async function heavyComputation(data) {
  return processHugeArray(data); // Blocks entire Node.js process!
}

// ✅ Offload to worker thread or yield periodically
async function heavyComputationNonBlocking(data) {
  const chunkSize = 10000;
  for (let i = 0; i < data.length; i += chunkSize) {
    const chunk = data.slice(i, i + chunkSize);
    processChunk(chunk);
    await new Promise(r => setTimeout(r, 0)); // Yield to event loop
  }
}
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Pattern Code
Create Promise new Promise((resolve, reject) => {...})
Wait for all await Promise.all([p1, p2])
All settled await Promise.allSettled([p1, p2])
First to finish await Promise.race([p1, p2])
First success await Promise.any([p1, p2])
Sequential loop for (const x of xs) { await f(x); }
Parallel loop await Promise.all(xs.map(x => f(x)))
Error handling try { ... } catch (e) { ... }
Timeout Promise.race([fetch(), timeout(ms)])
Retry Loop with exponential backoff
Async generator async function* gen() { yield await ... }
Consume generator for await (const x of gen()) { ... }

What's your favorite async pattern? Still using Promises or fully team async/await?

Follow @armorbreak for more practical JS guides.

Top comments (0)