DEV Community

Alex Chen
Alex Chen

Posted on

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

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

Async JavaScript is everywhere — but most developers only scratch the surface. Here's the complete picture from callbacks to modern async patterns.

The Evolution of Async JavaScript

// Phase 1: Callbacks (the old way — avoid "callback hell")
fs.readFile('data.json', 'utf8', function(err, data) {
  if (err) return console.error(err);
  fs.writeFile('output.json', data, function(err) {
    if (err) return console.error(err);
    console.log('Done!');
    // Nesting deeper with each step = callback hell!
  });
});

// Phase 2: Promises (ES2015 — chainable, composable)
readFilePromise('data.json')
  .then(data => writeFilePromise('output.json', data))
  .then(() => console.log('Done!'))
  .catch(err => console.error(err)); // Single error handler!

// Phase 3: Async/Await (ES2017 — synchronous-looking code)
async function processFile() {
  try {
    const data = await readFilePromise('data.json');
    await writeFilePromise('output.json', data);
    console.log('Done!');
  } catch (err) {
    console.error(err);
  }
}
// This is the same as Phase 2 but reads top-to-bottom!
Enter fullscreen mode Exit fullscreen mode

Promises Deep Dive

// Creating promises:
const myPromise = new Promise((resolve, reject) => {
  // Async operation here:
  const success = doSomething();
  if (success) resolve(resultValue);   // Fulfilled!
  else reject(errorReason);            // Rejected!
});

// Promise states:
// 1. Pending: Initial state, neither fulfilled nor rejected
// 2. Fulfilled: Operation completed successfully (.then fires)
// 3. Rejected: Operation failed (.catch fires)
// 4. Settled: Either fulfilled or rejected (final state, won't change)

// Static methods:

// Promise.resolve() / Promise.reject():
const resolved = Promise.resolve(42);           // Already fulfilled
const rejected = Promise.reject(new Error('fail')); // Already rejected

// Promise.all(): Run multiple promises, wait for ALL to settle
// ⚠️ If ANY rejects → entire Promise.all rejects immediately!
const results = await Promise.all([
  fetchUser(id),
  fetchPosts(id),
  fetchComments(id),
]);
// results = [user, posts, comments] — in order!

// Promise.allSettled(): Wait for ALL, never rejects (returns status for each)
const outcomes = await Promise.allSettled([
  maybeFails(),
  alsoMaybeFails(),
]);
outcomes.forEach(o => {
  if (o.status === 'fulfilled') use(o.value);
  else handleFailure(o.reason); // Doesn't throw!
});

// Promise.race(): Returns result/rejection of FIRST to settle
const result = await Promise.race([
  fetchFromPrimary(),
  timeoutAfter(3000), // Rejects after 3 seconds
]);

// Promise.any(): Returns result of FIRST to FULFILL (ignores rejections until all fail)
const firstSuccess = await Promise.any([
  tryServerA(),
  tryServerB(),
  tryServerC(),
]); // First one that works wins! All fail → AggregateError

// Chaining: Each .then() returns a new promise
fetch('/api/user')
  .then(res => res.json())         // Transform response
  .then(user => {                  // Use transformed value
    console.log(user.name);
    return user.id;                // Pass to next .then()
  })
  .then(userId => fetch(`/api/posts/${userId}`))
  .then(res => res.json())
  .then(posts => console.log(posts))
  .catch(err => {                   // Catches errors from ANY step above
    console.error('Failed:', err.message);
  })
  .finally(() => {                 // ALWAYS runs (success or failure)
    loadingSpinner.style.display = 'none';
  });

// Common mistake: Forgetting to return in .then():
fetch('/api/data')
  .then(res => res.json())        // ✅ Returns promise
  .then(data => {                 // ❌ No return! Next .then gets undefined
    processData(data);             // Fix: return processData(data)
  })
  .then(result => console.log(result)); // undefined!
Enter fullscreen mode Exit fullscreen mode

Async/Await Mastery

// Basic syntax:
async function fetchData(url) {
  const response = await fetch(url);     // Pauses here until fetch resolves
  const data = await response.json();    // Then pauses here until json parses
  return data;
}

// Arrow async:
const getData = async () => { ... };

// Object method async:
const obj = { async fetch() { ... } };

// Class method async:
class Service { async load() { ... } }

// Parallel awaits vs sequential (CRITICAL performance difference):

// ❌ Sequential (SLOW — each waits for the previous):
async function getUserDataSlow(userId) {
  const user = await db.users.findById(userId);      // 100ms
  const posts = await db.posts.findByUser(userId);   // +150ms
  const comments = await db.comments.findFor(posts);  // +80ms
  return { user, posts, comments };                   // Total: ~330ms
}

// ✅ Parallel (FAST — all run simultaneously):
async function getUserDataFast(userId) {
  const [user, posts, comments] = await Promise.all([
    db.users.findById(userId),       // 100ms
    db.posts.findByUser(userId),      // 150ms (runs in parallel!)
    db.comments.findForPosts(userId), // 80ms (runs in parallel!)
  ]);
  return { user, posts, comments };     // Total: ~150ms (slowest one wins)
}

// Error handling patterns:

// Pattern 1: Try/catch around everything
async function safeOperation() {
  try {
    const data = await riskyCall();
    return { success: true, data };
  } catch (err) {
    logError(err);
    return { success: false, error: err.message };
  }
}

// Pattern 2: Individual error handling per operation
async function multiStep() {
  let user, posts;

  try {
    user = await fetchUser(); // If this fails...
  } catch (err) {
    user = getDefaultUser();  // ...use fallback instead of crashing
  }

  try {
    posts = await fetchPosts(user.id);
  } catch (err) {
    posts = []; // Empty array fallback
  }

  return { user, posts };
}

// Pattern 3: Higher-order error wrapper (reusable!)
function withFallback(fn, fallback) {
  return async (...args) => {
    try { return await fn(...args); }
    catch (err) { 
      console.warn(`${fn.name} failed, using fallback:`, err.message);
      return typeof fallback === 'function' ? fallback(...args) : fallback;
    }
  };
}

const safeFetch = withFallback(fetchData, []);
const safeUser = withFallback(getUser, { name: 'Guest' });

// Awaiting non-promise values (works fine):
const result = await 42;              // Resolves immediately to 42
const result = await null;            // Resolves to null
const result = await Promise.resolve(42); // Same as above

// Top-level await (in ES modules only):
// module.js (with "type": "module" in package.json):
const config = await fetchConfig();    // Works at top level!
console.log(config.apiKey);          // Waits for config before continuing
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

// Retry pattern with exponential backoff:
async function retry(fn, options = {}) {
  const {
    maxAttempts = 3,
    baseDelay = 1000,
    maxDelay = 30000,
    backoff = 2,
    retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED'],
  } = options;

  let lastError;

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

      const isRetryable = retryableErrors.some(code =>
        err.code === code || err.message?.includes(code)
      );

      if (!isRetryable || attempt === maxAttempts) throw err;

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

// Usage:
const data = await retry(() => fetchFromAPI(endpoint), { maxAttempts: 5 });

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

// Usage:
const user = await withTimeout(fetchUser(id), 5000, new Error('DB query timed out'));

// Request concurrency limiter:
class ConcurrencyLimiter {
  constructor(maxConcurrent) {
    this.max = maxConcurrent;
    this.running = 0;
    this.queue = [];
  }

  async run(fn) {
    if (this.running >= this.max) {
      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()();
      }
    }
  }
}

const limiter = new ConcurrencyLimiter(5); // Max 5 concurrent requests
urls.forEach(url => limiter.run(() => fetchAndProcess(url)));

// Async iterator pattern (for streaming/pagination):
async function* paginate(apiFn, params) {
  let page = 1;
  while (true) {
    const result = await apiFn({ ...params, page, limit: 100 });
    yield result.items;
    if (result.items.length < 100 || page >= result.totalPages) break;
    page++;
  }
}

for await (const batch of paginate(fetchUsers, {})) {
  processBatch(batch); // Process 100 at a time
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes & Anti-Patterns

// ❌ Anti-pattern #1: Fire-and-forget without error handling
async function updateUser(userId) {
  await db.users.update(userId, { name: 'New Name' }); // If this throws...
  sendEmailNotification(userId);                           // ...this NEVER runs!
}
// Fix: Wrap in try/catch or ensure errors propagate

// ❌ Anti-pattern #2: Unnecessary await
const userId = await req.user.id;  // req.user.id is NOT a promise!
// Remove await — it adds unnecessary microtask overhead

// ❌ Anti-pattern #3: Using await inside Promise constructor
new Promise(async (resolve, reject) => {  // DON'T DO THIS!
  const data = await fetchData();       // Errors here won't be caught by reject!
  resolve(data);
});
// Fix: Just make the outer function async instead

// ❌ Anti-pattern #4: Mixing callbacks and promises incorrectly
fs.readFile('file.txt', (err, data) => {
  // This callback doesn't return anything to a promise chain
});
// Fix: Use fs.promises.readFile or util.promisify(fs.readFile)

// ❌ Anti-pattern #5: Forgetting that map + async = fire-and-forget
const users = [1, 2, 3];
const results = users.map(async id => {  // Returns array of PROMISES, not values!
  return await fetchUser(id);
});
// Fix: await Promise.all(users.map(async id => await fetchUser(id)))

// ❌ Anti-pattern #6: catch blocks that swallow errors silently
try { await riskyOp(); } catch (e) { /* oops */ }  // Error disappears forever!
// Fix: At minimum: log it. Better: re-throw or handle properly.
Enter fullscreen mode Exit fullscreen mode

What's your favorite async pattern? What async pitfall has bitten you before?

Follow @armorbreak for more practical developer guides.

Top comments (0)