DEV Community

Alex Chen
Alex Chen

Posted on

JavaScript Promises, Async/Await, and Error Handling: The Complete Guide (2026)

JavaScript Promises, Async/Await, and Error Handling: The Complete Guide (2026)

Async JavaScript is everywhere. Promises and async/await are the foundation of modern JS. Master them once, use them forever.

From Callbacks to Promises

// === The Problem: Callback Hell ===
// Nested callbacks make code hard to read and error-prone

getUser(userId, function(user) {
  getPosts(user.id, function(posts) {
    getComments(posts[0].id, function(comments) {
      // 4 levels deep! And this is a simple example.
      // Real code can go 10+ levels deep.
    });
  });
});

// === The Solution: Promises (ES2015) ===
// A Promise is a placeholder for a future value
// Three states: Pending → Fulfilled or Rejected

const promise = new Promise((resolve, reject) => {
  // Async work happens here
  const success = true;
  if (success) {
    resolve('Success!'); // Fulfills the promise with a value
  } else {
    reject(new Error('Failed!')); // Rejects the promise with an error
  }
});

// Consuming promises:
promise
  .then(result => console.log(result))   // On success
  .catch(err => console.error(err))       // On failure
  .finally(() => console.log('Done'));   // Always runs (cleanup)

// Chaining promises (no more nesting!):
getUser(userId)
  .then(user => getPosts(user.id))
  .then(posts => getComments(posts[0].id))
  .then(comments => console.log(comments))
  .catch(err => console.error('Error anywhere in chain:', err));
// Flat structure! Error handling in ONE place.
Enter fullscreen mode Exit fullscreen mode

Creating Your Own Promises

// Wrapping callback-based APIs in Promises:
function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

// Usage:
readFilePromise('/etc/hostname')
  .then(data => console.log('Hostname:', data))
  .catch(err => console.error('Read failed:', err));

// Promise.resolve / Promise.reject (for quick returns):
function getCachedData(key) {
  const cached = cache.get(key);
  if (cached) return Promise.resolve(cached); // Already have it, wrap in promise

  return fetchDataFromAPI(key); // Returns a real promise
}

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

withTimeout(fetchData(), 5000); // Rejects after 5 seconds if not resolved
Enter fullscreen mode Exit fullscreen mode

Promise Combinators

// Promise.all — Run all, fail if ANY fails (fast failure)
const [users, posts, comments] = await Promise.all([
  fetchUsers(),
  fetchPosts(),
  fetchComments(),
]);
// All must succeed. If one fails, entire thing fails.

// Promise.allSettled — Run ALL, wait for ALL to finish (never fails!)
const results = await Promise.allSettled([
  fetchUsers(),     // { status: "fulfilled", value: [...] }
  fetchPosts(),     // { status: "rejected", reason: Error }
  fetchComments(),  // { status: "fulfilled", value: [...] }
]);
results.forEach(r => {
  if (r.status === 'fulfilled') {
    console.log('Got:', r.value);
  } else {
    console.log('Failed:', r.reason);
  }
});

// Promise.race — Returns first to settle (win or lose)
const result = await Promise.race([
  fetchFromPrimary(),
  withTimeout(fetchFromBackup(), 2000), // Must be faster than primary
]);
// Whichever resolves/rejects first wins

// Promise.any — Returns first SUCCESSFUL one (ignores failures until all fail)
const firstWorking = await Promise.any([
  fetchFromCDN1(),
  fetchFromCDN2(),
  fetchFromCDN3(),
]);
// Returns first successful result. Only rejects if ALL fail.

// Practical example: Fetch with fallback URLs:
async function fetchWithFallback(urls) {
  let lastError;
  for (const url of urls) {
    try {
      return await fetch(url).then(r => r.json());
    } catch (err) {
      lastError = err;
      console.warn(`${url} failed, trying next...`);
    }
  }
  throw lastError; // All failed
}
Enter fullscreen mode Exit fullscreen mode

Async/Await: Syntactic Sugar That Changes Everything

// async/await makes async code look synchronous!

// Before (promise chains):
function getUserPosts(userId) {
  return getUser(userId)
    .then(user => getPosts(user.id))
    .then(posts => posts.filter(p => p.published))
    .then(filtered => ({ count: filtered.length, posts: filtered }));
}

// After (async/await):
async function getUserPosts(userId) {
  const user = await getUser(userId);         // Wait for user
  const posts = await getPosts(user.id);      // Wait for posts
  const filtered = posts.filter(p => p.published); // Sync-looking!
  return { count: filtered.length, posts: filtered };
}
// Same behavior, MUCH more readable.

// Error handling with try/catch (familiar from sync code):
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) {
    logger.error(`Failed to fetch ${url}:`, err.message);
    return null; // Graceful fallback
  }
}

// Parallel execution (CRITICAL performance tip!):
// ❌ Sequential (slow — each waits for previous):
async function getAllData() {
  const users = await fetchUsers();     // Wait 100ms
  const posts = await fetchPosts();      // Wait 150ms
  const comments = await fetchComments(); // Wait 80ms
  // Total: ~330ms
}

// ✅ Parallel (fast — all start at once):
async function getAllData() {
  const [users, posts, comments] = await Promise.all([
    fetchUsers(),     // Start immediately
    fetchPosts(),      // Start immediately
    fetchComments(),  // Start immediately
  ]);
  // Total: ~150ms (longest single request)
}
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

// Pattern 1: Retry logic with exponential backoff
async function retry(fn, options = {}) {
  const { maxAttempts = 3, baseDelay = 1000, maxDelay = 30000 } = options;
  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      if (attempt === maxAttempts) throw err; // Final attempt, give up

      const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
      console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage:
const data = await retry(() => flakyAPI.call(), { maxAttempts: 5 });

// Pattern 2: Request queueing (limit concurrent requests)
class RequestQueue {
  constructor(concurrency = 5) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
  }

  async run(fn) {
    if (this.running >= this.concurrency) {
      await new Promise(resolve => this.queue.push(resolve));
    }
    this.running++;
    try {
      return await fn();
    } finally {
      this.running--;
      if (this.queue.length > 0) {
        this.queue.shift()(); // Let next waiting request proceed
      }
    }
  }
}

const queue = new RequestQueue(3); // Max 3 concurrent requests
urls.forEach(url => queue.run(() => fetchAndProcess(url)));

// Pattern 3: Caching promises (deduplicate in-flight requests)
const pendingRequests = new Map();

function deduplicatedFetch(url) {
  if (pendingRequests.has(url)) {
    return pendingRequests.get(url); // Return same promise if already in-flight
  }

  const promise = fetch(url)
    .then(res => res.json())
    .finally(() => pendingRequests.delete(url)); // Clean up when done

  pendingRequests.set(url, promise);
  return promise;
}

// If 10 components call deduplicatedFetch('/api/user/123') simultaneously,
// only ONE actual HTTP request is made!

// Pattern 4: Async iterator / generator
async function* paginateAPI(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 || data.items.length === 0) break;

    yield data.items; // Yield page of results
    page++;

    if (data.items.length < pageSize) break; // Last page
  }
}

// Usage:
for await (const items of paginateAPI('/api/posts')) {
  console.log(`Processing ${items.length} posts`);
  processBatch(items);
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes & How to Avoid Them

// ❌ Forgetting await (unhandled promise!)
function updateUser(id, data) {
  db.users.update(id, data); // Missing await! Promise floats away
  // No error handling, no guarantee it completes
}
// Fix: ALWAYS await promises (or explicitly return them)

// ❌ try/catch inside loop (loses error context)
async function processItems(items) {
  for (const item of items) {
    try {
      await process(item);
    } catch (err) {
      console.error('Failed:', item.id); // Which item? Need more context
    }
  }
}
// Better: Collect errors, report together
async function processItems(items) {
  const errors = [];
  for (const item of items) {
    try {
      await process(item);
    } catch (err) {
      errors.push({ id: item.id, error: err.message });
    }
  }
  if (errors.length > 0) {
    throw new Error(`${errors.length} items failed: ${JSON.stringify(errors)}`);
  }
}

// ❌ Fire-and-forget without error handling
res.send('Processing started...');
heavyAsyncTask(); // If this crashes, nobody knows!
// Fix: At minimum:
heavyAsyncTask().catch(err => logger.error('Background task failed', err));
// Or use a proper job queue (Bull, Agenda, etc.)

// ❌ Mixing callbacks and promises (Zalgo!)
function badFunction(callback) {
  if (cache.has(key)) {
    callback(null, cache.get(key)); // Synchronous!
  } else {
    fetchData().then(data => callback(null, data)); // Asynchronous!
  }
}
// Caller can't predict if callback fires before or after function returns
// Fix: Always use promises (or always synchronous)

// ❌ Unhandled rejection in Promise constructor
new Promise((resolve, reject) => {
  throw new Error('Oops'); // This IS caught by Promise (rejects automatically)
});
// But INSIDE .then() handlers, throws are NOT caught:
Promise.resolve(42)
  .then(val => { throw new Error('Not caught here!'); })
  .catch(err => console.log('Caught:', err)); // THIS catches it
// Rule: Always end chain with .catch()

// ❌ Awaiting non-Promise values (unnecessary but works)
const result = await 42;        // Works but pointless
const name = await "Alice";    // Same
// Not harmful, just unnecessary. Linters will flag it.
Enter fullscreen mode Exit fullscreen mode

What's your favorite async pattern? What async nightmare have you survived?

Follow @armorbreak for more practical developer guides.

Top comments (1)

Collapse
 
aadswebdesign profile image
Aad Pouw

What I use a lot is this (those are not fetch related):

(async ()=>{
  // stuff
})();
Enter fullscreen mode Exit fullscreen mode

When needed:

(async ()=>{
   //stuff
})().then(()=>{
 // following up stuff 
});
Enter fullscreen mode Exit fullscreen mode

I use this in class constructors too and is often more convenient as adding methods!