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 code doesn't have to be confusing. Master these patterns and you'll handle any async scenario.

The Evolution of Async in JavaScript

Callback Hell → Promises → Async/Await → Top-level Await

Each step solved real problems:
→ Callbacks: No composition, nesting nightmare
→ Promises: Composable but still .then() chains
→ Async/Await: Reads like synchronous code
→ Top-level Await: Use await in modules (Node.js 14+)
Enter fullscreen mode Exit fullscreen mode

Callbacks: Where It Started

// The old way (still used in Node.js core APIs)
const fs = require('fs');

// ❌ Callback hell (pyramid of doom)
fs.readFile('file1.txt', 'utf8', (err, data1) => {
  if (err) return console.error(err);
  fs.readFile('file2.txt', 'utf8', (err, data2) => {
    if (err) return console.error(err);
    fs.writeFile('output.txt', data1 + data2, (err) => {
      if (err) return console.error(err);
      console.log('Done!');
    });
  });
});

// Problems:
// → Error handling repeated at every level
// → Deeply nested, hard to read
// → Can't easily compose multiple async operations
// → Control flow is inverted (you don't call, you get called)
Enter fullscreen mode Exit fullscreen mode

Promises: The Foundation

Creating Promises

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

// Promise states:
// → Pending: Initial state, neither fulfilled nor rejected
// → Fulfilled: Operation completed successfully (has a value)
// → Rejected: Operation failed (has a reason/error)
// → Settled: Either fulfilled or rejected (final state)

// Once settled, a promise CANNOT change state again.
// This immutability is the key to reliable async code.
Enter fullscreen mode Exit fullscreen mode

Consuming Promises

fetchData('https://api.example.com/data')
  .then(data => {
    console.log('Got:', data);
    return JSON.parse(data); // Return value becomes next .then()'s input
  })
  .then(parsed => {
    console.log('Parsed:', parsed);
    return parsed.items; // Pass only items to next handler
  })
  .then(items => {
    console.log(`Found ${items.length} items`);
    return processItems(items); // Returns another Promise!
  })
  .catch(error => {           // Catches ANY rejection in the chain
    console.error('Something failed:', error.message);
    // If you want the chain to continue, return a value here
    return []; // Fallback value
  })
  .finally(() => {            // ALWAYS runs, regardless of success/failure
    console.log('Request complete');
    loadingIndicator.hide();   // Cleanup code goes here
  });

// Key insight: .catch() is also a .then(undefined, onRejected)
// So it returns a new Promise, allowing chaining to continue!
Enter fullscreen mode Exit fullscreen mode

Common Promise Patterns

// Pattern 1: Parallel execution (all start immediately)
Promise.all([
  fetchUser(userId),
  fetchOrders(userId),
  fetchPreferences(userId),
])
  .then(([user, orders, prefs]) => {
    // All resolved! Data arrives in order.
    renderDashboard({ user, orders, prefs });
  })
  .catch(err => {
    // ANY one rejects → entire Promise.all rejects
    console.error('Failed to load dashboard:', err);
  });

// Pattern 2: Race (first to settle wins)
const timeout = new Promise((_, reject) =>
  setTimeout(() => reject(new Error('Timeout')), 5000)
);

Promise.race([fetchData(url), timeout])
  .then(data => console.log(data))
  .catch(err => console.error(err)); // Could be timeout OR fetch error

// Pattern 3: AllSettled (wait for ALL, collect results/errors)
Promise.allSettled([
  fetch('/api/a'),
  fetch('/api/b'),
  fetch('/api/c'), // This might fail
])
  .then(results => {
    results.forEach((result, i) => {
      if (result.status === 'fulfilled') {
        console.log(`API ${i}:`, result.value);
      } else {
        console.warn(`API ${i} failed:`, result.reason);
      }
    });
  });

// Pattern 4: Any (first SUCCESS wins)
Promise.any([
  fetchFromPrimary(url),
  fetchFromBackup(url),
  fetchFromCache(url),
])
  .then(data => console.log('Got data from somewhere!'))
  .catch(err => {
    // All rejected (AggregateError with all reasons)
    console.error('All sources failed');
  });

// Pattern 5: Sequential execution (each waits for previous)
async function processItemsSequentially(items) {
  const results = [];
  for (const item of items) {
    const result = await processItem(item); // Wait for each
    results.push(result);
  }
  return results;
}

// vs parallel (all at once):
async function processItemsParallel(items) {
  return Promise.all(items.map(item => processItem(item)));
}
Enter fullscreen mode Exit fullscreen mode

Async/Await: Syntactic Sugar That Changes Everything

Basic Syntax

// async function ALWAYS returns a Promise
async function getUser(id) {
  // Inside async functions, use await instead of .then()
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();

  // This reads TOP TO BOTTOM like synchronous code!
  // No nesting, no .then() chains, no callback pyramids

  return user;
  // Implicitly wrapped in Promise.resolve(user)
}

// Calling an async function always returns a Promise
getUser(123).then(user => console.log(user));

// You can also destructure the await:
const { name, email } = await getUser(123);

// Works in arrow functions too:
const getData = async () => {
  const res = await fetch('/data');
  return res.json();
};
Enter fullscreen mode Exit fullscreen mode

Error Handling with Try/Catch

// This is where async/await REALLY shines over .then().catch()
async function robustFetch(url) {
  try {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 10000);

    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);

    if (!response.ok) {
      // HTTP errors (4xx, 5xx) do NOT reject! Handle explicitly.
      const errorBody = await response.json().catch(() => ({}));
      throw new FetchError(
        `HTTP ${response.status}: ${response.statusText}`,
        response.status,
        errorBody
      );
    }

    return await response.json();

  } catch (error) {
    if (error.name === 'AbortError') {
      throw new FetchError('Request timed out', 408);
    }
    if (error.code === 'ECONNREFUSED') {
      throw new FetchError('Server unreachable', 503);
    }
    // Re-throw unknown errors
    throw error;
  }
}

class FetchError extends Error {
  constructor(message, status, body) {
    super(message);
    this.name = 'FetchError';
    this.status = status;
    this.body = body;
  }
}

// Usage:
try {
  const data = await robustFetch('/api/data');
  console.log(data);
} catch (error) {
  if (error instanceof FetchError) {
    if (error.status === 408) showTimeoutMessage();
    else if (error.status === 404) showNotFound();
    else showError(error.message);
  } else {
    showError('Unexpected error');
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

// Pattern 1: Retry with exponential backoff
async function retry(fn, maxAttempts = 3, baseDelay = 1000) {
  let lastError;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      if (attempt < maxAttempts) {
        const delay = baseDelay * Math.pow(2, attempt - 1); // 1s, 2s, 4s
        console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
        await sleep(delay);
      }
    }
  }
  throw lastError; // All attempts exhausted
}

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

// Usage:
const data = await retry(() => fetchAPI('/expensive-operation'));

// Pattern 2: Concurrent with concurrency limit
async function concurrentMap(items, fn, concurrency = 5) {
  const results = [];
  let index = 0;

  async function worker() {
    while (index < items.length) {
      const currentIndex = index++;
      results[currentIndex] = await fn(items[currentIndex], currentIndex);
    }
  }

  const workers = Array.from({ length: Math.min(concurrency, items.length) }, worker);
  await Promise.all(workers);
  return results;
}

// Process 100 API calls, 5 at a time:
const results = await concurrentMap(urls, url => fetch(url).then(r => r.json()), 5);

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

function cachedFetch(url) {
  if (pendingRequests.has(url)) {
    return pendingRequests.get(url); // Return existing Promise
  }

  const promise = fetch(url)
    .then(res => res.json())
    .finally(() => pendingRequests.delete(url));

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

// Multiple components can call cachedFetch simultaneously,
// but only ONE actual network request is made.

// Pattern 4: Timeout wrapper
async function withTimeout(promise, ms, message = 'Operation timed out') {
  const timer = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(message)), ms)
  );
  return Promise.race([promise, timer]);
}

// Usage:
const user = await withTimeout(fetchUser(id), 3000, 'User lookup timed out');

// Pattern 5: Async iterator (for streaming/pagination)
async function* paginate(endpoint, pageSize = 50) {
  let page = 1;
  while (true) {
    const response = await fetch(`${endpoint}?page=${page}&size=${pageSize}`);
    const { items, hasMore } = await response.json();

    yield* items; // Yield each item individually

    if (!hasMore || items.length === 0) break;
    page++;
  }
}

// Usage with for-await-of:
for await (const item of paginate('/api/users')) {
  console.log(item.name);
  // Processes items as they arrive, not after ALL pages load
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes & Anti-Patterns

// ❌ Mistake 1: Forgetting await in loops
// This fires ALL requests simultaneously without waiting:
urls.forEach(async url => {
  const data = await fetch(url); // Each runs independently
  processData(data);
});
// Fix: Use for...of or Promise.all

// ❌ Mistake 2: Fire-and-forget (unhandled promise rejection)
async function updateUser(id) {
  await fetch(`/api/users/${id}`, { method: 'PATCH', body: {...} });
  // What if this fails? Silent failure!
}
updateUser(123); // No .catch(), no await → unhandled rejection!

// Fix: Always handle or at least suppress intentionally:
updateUser(123).catch(console.error);

// ❌ Mistake 3: Unnecessary async
async function getValue() {
  return 42; // Wrapping synchronous value in Promise unnecessarily
}
// Fix: Just return the value directly (unless API requires Promise)

// ❌ Mistake 4: try/catch around everything
try {
  const a = await syncFunction(); // Not needed for sync code
  const b = await anotherSync();
} catch (e) { ... }

// ❌ Mistake 5: Mixing callbacks and promises
fs.readFile('file.txt', (err, data) => {
  // Now you're back in callback land inside an async function
});

// Fix: Use fs.promises (or util.promisify):
import { readFile } from 'fs/promises';
const data = await readFile('file.txt', 'utf8'); // Clean!

// ❌ Mistake 6: Awaiting inside map without returning promises
const results = items.map(async item => {
  return await process(item); // Returns array of Promises, NOT results!
});
// results is [Promise, Promise, ...] not [result, result, ...]

// Fix: Use Promise.all:
const results = await Promise.all(items.map(item => process(item)));
Enter fullscreen mode Exit fullscreen mode

Performance Tips

// Tip 1: Parallel > Sequential when order doesn't matter
// Slow: Each request waits for the previous
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();

// Fast: All fire at once
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
// Total time = max(a, b, c) instead of a + b + c

// Tip 2: Don't await outside try/catch if you want partial success
const results = await Promise.allSettled(
  items.map(item => riskyOperation(item))
);
const successes = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failures = results.filter(r => r.status === 'rejected').map(r => r.reason);

// Tip 3: Lazy evaluation for expensive operations
function lazyAsync(fn) {
  let cache;
  return () => cache ??= fn(); // Compute once, reuse forever
}

const heavyData = lazyAsync(() => fetchExpensiveData());
// Only actually fetches when first awaited

// Tip 4: Use AbortController for cancellable operations
function cancellableFetch(url, signal) {
  return fetch(url, { signal }); // Pass signal through
}

const controller = new AbortController();
const promise = cancellableFetch(url, controller.signal);

// Later, if user navigates away:
controller.abort(); // Cancels the fetch!

// Tip 5: Batch small operations
async function batchWrite(items, batchSize = 10) {
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    await db.batchInsert(batch); // One DB round-trip per batch
  }
}
Enter fullscreen mode Exit fullscreen mode

What's your favorite async pattern? Which one took you longest to understand?

Follow @armorbreak for more practical developer guides.

Top comments (0)