Understand the Asynchronous Javascript Callbacks Promises and Async Await

Updated: March 27, 2026

Understand the Asynchronous Javascript Callbacks Promises and Async Await

TL;DR

JavaScript asynchrony evolved from callbacks (which led to pyramid-of-doom nesting) to Promises (chainable, better error handling) to async/await (synchronous-looking code with full async power)—each abstraction builds on the last, addressing previous limitations.

Asynchronous programming is fundamental to JavaScript. Whether fetching data, reading files, or handling user interactions, async operations are everywhere. But async wasn't always elegant—developers once struggled with callback hell, pyramid of doom, and tangled error handling. This guide traces the evolution from callbacks to Promises to async/await, showing how each innovation solved previous problems while introducing new patterns. By the end, you'll understand not just how to use async/await, but why it's the default choice in 2026.

Callbacks: The Original Pattern

Callbacks are functions passed as arguments to execute after an operation completes.

Basic Callback Pattern

// Callback function (synchronous use)
const processData = (data, callback) => {
  callback(data.toUpperCase());
};

processData('hello', result => {
  console.log(result); // 'HELLO'
});

// Asynchronous callback
const fetchData = (url, callback) => {
  setTimeout(() => {
    callback({ id: 1, name: 'Alice' });
  }, 100);
};

fetchData('/api/user', user => {
  console.log('User:', user.name);
});

Callback Hell (Pyramid of Doom)

Callbacks become problematic when operations nest—each level indents deeper, creating hard-to-read code.

// Callback hell: deeply nested callbacks
getUser(userId, user => {
  getPosts(user.id, posts => {
    getComments(posts[0].id, comments => {
      getAuthor(comments[0].authorId, author => {
        console.log('Final author:', author.name);
        // We're 4 levels deep!
      });
    });
  });
});

// Problems:
// 1. Hard to read and maintain
// 2. Error handling is awkward (need try-catch at each level)
// 3. Code flows right, not down
// 4. Difficult to parallelize operations

Error Handling with Callbacks

Callbacks lack built-in error handling—you must manually pass error objects or separate error callbacks.

// Convention: error-first callbacks
const readFile = (filename, callback) => {
  setTimeout(() => {
    if (filename === 'missing.txt') {
      callback(new Error('File not found'), null);
    } else {
      callback(null, 'File contents');
    }
  }, 100);
};

readFile('data.txt', (error, data) => {
  if (error) {
    console.error('Error:', error.message);
  } else {
    console.log('Data:', data);
  }
});

// Nested error handling (tedious)
readFile('file1.txt', (err1, data1) => {
  if (err1) {
    console.error(err1);
    return;
  }
  readFile('file2.txt', (err2, data2) => {
    if (err2) {
      console.error(err2);
      return;
    }
    console.log('Both files:', data1, data2);
  });
});

Promises: Chainable Async

Promises represent a future value and provide .then(), .catch(), and .finally() for cleaner composition.

Creating and Using Promises

// Creating a Promise
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Success!');
    // or: reject(new Error('Failed!'));
  }, 100);
});

// Consuming a Promise
promise
  .then(result => {
    console.log(result); // 'Success!'
    return result + ' More work';
  })
  .then(result => {
    console.log(result); // 'Success! More work'
  })
  .catch(error => {
    console.error('Error:', error.message);
  })
  .finally(() => {
    console.log('Operation complete');
  });

Chaining vs Nesting

Promises eliminate nesting by allowing chains.

// Without Promises (callback hell)
getUser(userId, user => {
  getPosts(user.id, posts => {
    getComments(posts[0].id, comments => {
      console.log('Comments:', comments);
    });
  });
});

// With Promises (clean chain)
getUser(userId)
  .then(user => getPosts(user.id))
  .then(posts => getComments(posts[0].id))
  .then(comments => {
    console.log('Comments:', comments);
  })
  .catch(error => {
    console.error('Error:', error.message);
  });

// Promise-returning helpers
const getUser = (id) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id, name: 'Alice' });
    }, 100);
  });
};

const getPosts = (userId) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([{ id: 1, title: 'Post 1' }]);
    }, 100);
  });
};

Error Propagation

In Promise chains, errors propagate to the first .catch() handler.

Promise.resolve()
  .then(() => {
    throw new Error('Step 1 failed');
  })
  .then(() => {
    console.log('Step 2 (skipped)');
  })
  .then(() => {
    console.log('Step 3 (skipped)');
  })
  .catch(error => {
    console.error('Caught error:', error.message); // 'Step 1 failed'
  });

// Error recovery
Promise.reject('Network error')
  .catch(error => {
    console.log('Retrying...');
    return 'recovered'; // New Promise with value 'recovered'
  })
  .then(result => {
    console.log('Recovered:', result); // 'Recovered: recovered'
  });

Parallel Operations with Promise.all()

// Fetch multiple resources in parallel
const userId = 1;

Promise.all([
  fetch(`/api/users/${userId}`).then(r => r.json()),
  fetch(`/api/posts/${userId}`).then(r => r.json()),
  fetch(`/api/comments/${userId}`).then(r => r.json())
])
  .then(([user, posts, comments]) => {
    console.log('All data:', { user, posts, comments });
  })
  .catch(error => {
    console.error('One of the requests failed:', error);
  });

// Promise.race: return first completed Promise
Promise.race([
  fetch('/api/data'),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Timeout')), 5000)
  )
])
  .then(response => console.log('Response received'))
  .catch(error => console.error('Request timeout or failed'));

Async/Await: Synchronous-Looking Async Code

async/await is syntactic sugar over Promises that lets you write async code that reads like synchronous code.

Basic async/await

// Function declaration
async function fetchUser(id) {
  // Inside async function, you can use 'await'
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user; // Returns a Promise
}

// Arrow function
const fetchPost = async (id) => {
  const response = await fetch(`/api/posts/${id}`);
  return response.json();
};

// Usage
const user = await fetchUser(1);
console.log('User:', user);

// Note: await can only be used inside async functions
// (Top-level await is available in ES2022 modules)

Error Handling with try/catch

async function fetchUserData(id) {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const user = await response.json();
    return user;
  } catch (error) {
    console.error('Failed to fetch user:', error.message);
    throw error; // Re-throw or return fallback
  } finally {
    console.log('Fetch attempt completed');
  }
}

// Usage
try {
  const user = await fetchUserData(1);
  console.log('User:', user);
} catch (error) {
  console.error('Error in app:', error);
}

Parallel Operations with async/await

// Sequential: operations run one after another
async function fetchSequential(userId) {
  const user = await fetch(`/api/users/${userId}`).then(r => r.json());
  const posts = await fetch(`/api/posts/${userId}`).then(r => r.json());
  const comments = await fetch(`/api/comments/${userId}`).then(r => r.json());
  // Total time: sum of all requests
  return { user, posts, comments };
}

// Parallel: operations start simultaneously
async function fetchParallel(userId) {
  // Start all requests at once
  const userPromise = fetch(`/api/users/${userId}`).then(r => r.json());
  const postsPromise = fetch(`/api/posts/${userId}`).then(r => r.json());
  const commentsPromise = fetch(`/api/comments/${userId}`).then(r => r.json());

  // Wait for all to complete
  const [user, posts, comments] = await Promise.all([
    userPromise,
    postsPromise,
    commentsPromise
  ]);

  // Total time: max of all requests (much faster!)
  return { user, posts, comments };
}

// Using Promise.all with await
async function fetchWithAll(userId) {
  const [user, posts, comments] = await Promise.all([
    fetch(`/api/users/${userId}`).then(r => r.json()),
    fetch(`/api/posts/${userId}`).then(r => r.json()),
    fetch(`/api/comments/${userId}`).then(r => r.json())
  ]);

  return { user, posts, comments };
}

Retrying with Exponential Backoff

async function retryFetch(url, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.json();
    } catch (error) {
      if (attempt === maxRetries) {
        throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`);
      }

      // Exponential backoff: 100ms, 200ms, 400ms, etc.
      const delay = 100 * Math.pow(2, attempt - 1);
      console.log(`Attempt ${attempt} failed. Retrying in ${delay}ms...`);

      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
const data = await retryFetch('/api/unreliable-endpoint');
console.log('Data:', data);

AbortController: Cancellable Async Operations

AbortController lets you cancel fetch requests and other async operations.

// Create a controller
const controller = new AbortController();

// Pass its signal to fetch
const fetchPromise = fetch('/api/data', {
  signal: controller.signal
});

// Cancel after 5 seconds
const timeoutId = setTimeout(() => {
  controller.abort();
}, 5000);

// Handle cancellation
try {
  const response = await fetchPromise;
  const data = await response.json();
  console.log('Data:', data);
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request was cancelled');
  } else {
    console.error('Network error:', error);
  }
} finally {
  clearTimeout(timeoutId);
}

// Real-world: abort on user action
const controller2 = new AbortController();

const downloadButton = document.getElementById('download');
const cancelButton = document.getElementById('cancel');

downloadButton.addEventListener('click', async () => {
  try {
    const response = await fetch('/api/large-file', {
      signal: controller2.signal
    });
    const blob = await response.blob();
    console.log('Download complete');
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Download cancelled by user');
    }
  }
});

cancelButton.addEventListener('click', () => {
  controller2.abort();
});

Promise.withResolvers() (ES2024)

Modern way to create resolvable Promises without complex variable assignment.

// Before ES2024
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

// ES2024 with Promise.withResolvers()
const { promise, resolve, reject } = Promise.withResolvers();

// Practical: wrapping event-driven code
const { promise: clickPromise, resolve: resolveClick } = Promise.withResolvers();

document.getElementById('button').addEventListener('click', () => {
  resolveClick('Button clicked!');
});

const clickResult = await clickPromise;
console.log(clickResult); // 'Button clicked!'

Comparison: Callbacks vs Promises vs async/await

AspectCallbacksPromisesasync/await
ReadabilityPoor (pyramid of doom)Good (chainable)Excellent (synchronous)
Error HandlingManual, tediousBuilt-in .catch()try/catch blocks
CompositionDifficult.then() chainsSequential or Promise.all()
DebuggingStack traces lostStack traces preservedFull async stack
Learning CurveEasy initially, hard at scaleModerateModerate
PerformanceFastSlight overheadSame as Promises

Best Practices

1. Always Use async/await Over .then()

// Don't do this
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then(r => r.json())
    .then(user => user);
}

// Do this
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

2. Handle Errors Properly

// Don't ignore errors
async function risky() {
  await someAsyncOperation(); // What if this throws?
}

// Do this
async function safe() {
  try {
    await someAsyncOperation();
  } catch (error) {
    console.error('Operation failed:', error);
    // Handle or re-throw
  }
}

3. Use Promise.all() for Parallel Operations

// Don't do sequential when parallel is faster
async function slow(userId) {
  const user = await getUser(userId);
  const posts = await getPosts(userId);
  return { user, posts };
}

// Do this for parallelism
async function fast(userId) {
  const [user, posts] = await Promise.all([
    getUser(userId),
    getPosts(userId)
  ]);
  return { user, posts };
}

Conclusion

The evolution from callbacks to Promises to async/await represents a decade of JavaScript learning. Callbacks remain useful for synchronous operations and array methods, Promises form the foundation of modern JavaScript, and async/await is the ergonomic layer on top. Master all three—understand why async/await desugars to Promises, how Promises manage microtasks, and when to use AbortController for cancellation. With this foundation, you'll write reliable, readable, maintainable asynchronous JavaScript.


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.