Comparison

Async/Await vs Promises vs Callbacks: JavaScript Async Guide

Compare callbacks, Promises, and async/await in JavaScript. See side-by-side code, common bugs, error handling, and when to use each pattern.

Async/Await vs Promises vs Callbacks in JavaScript

Every JavaScript developer eventually hits the same wall: how do you handle work that takes time — a network request, a file read, a timer — without freezing the main thread? The language has offered three answers over its lifetime, and all three still ship in modern code. Understanding how they relate is the difference between writing readable async code and drowning in nested braces.

The Evolution

Callbacks came first. You pass a function to another function, and it gets invoked when the work finishes. Node.js standardized the error-first callback: the first argument is an error (or null), and the result follows. Simple, but composition is painful — nest three or four of them and you get callback hell, a rightward-drifting pyramid of doom.

Promises (ES2015) wrapped the eventual value in an object with .then() and .catch() methods. Instead of nesting, you chain. Errors bubble down the chain to a single .catch(), replacing the manual if (err) return callback(err) in every step.

async/await (ES2017) is syntactic sugar over Promises. An async function always returns a Promise, and await pauses execution until a Promise settles — without blocking the thread. The code reads like synchronous code, but the runtime is still fully asynchronous.

The Same Operation, Three Ways

Reading a file, parsing it as JSON, and writing a transformed copy:

Callbacks

const fs = require('fs');

fs.readFile('in.json', 'utf8', (err, data) => {
  if (err) return console.error(err);
  let parsed;
  try { parsed = JSON.parse(data); }
  catch (e) { return console.error(e); }
  parsed.updated = Date.now();
  fs.writeFile('out.json', JSON.stringify(parsed), (err) => {
    if (err) return console.error(err);
    console.log('done');
  });
});

Promises

const fs = require('fs').promises;

fs.readFile('in.json', 'utf8')
  .then(data => JSON.parse(data))
  .then(obj => { obj.updated = Date.now(); return obj; })
  .then(obj => fs.writeFile('out.json', JSON.stringify(obj)))
  .then(() => console.log('done'))
  .catch(err => console.error(err));

async/await

const fs = require('fs').promises;

async function update() {
  try {
    const data = await fs.readFile('in.json', 'utf8');
    const obj = JSON.parse(data);
    obj.updated = Date.now();
    await fs.writeFile('out.json', JSON.stringify(obj));
    console.log('done');
  } catch (err) {
    console.error(err);
  }
}

Each version does exactly the same thing. The async/await version is easiest to read, debug, and step through — the call stack in your debugger actually makes sense.

When Each Is Still Appropriate

  • Callbacks — still correct for APIs that fire multiple times: event emitters, addEventListener, streams, setInterval. Promises resolve exactly once, so they cannot model these.
  • Promises — best when you compose parallel work with Promise.all, allSettled, race, or any. Also the natural style for short pipelines where a chain reads better than four await statements.
  • async/await — the default for sequential work. Use it whenever the next step depends on the previous one, and whenever error handling benefits from ordinary try/catch.

Concurrency Combinators

These are Promise methods, but you use them with await:

  • Promise.all([...]) — resolves when all resolve; rejects on the first rejection. Use for "I need every result."
  • Promise.allSettled([...]) — waits for all to finish and returns {status, value|reason} for each. Use when partial failure is acceptable.
  • Promise.race([...]) — settles with the first to settle (resolve or reject). Useful for timeouts.
  • Promise.any([...]) — resolves with the first success; rejects only if all reject (AggregateError).
const [users, posts, tags] = await Promise.all([
  fetch('/users').then(r => r.json()),
  fetch('/posts').then(r => r.json()),
  fetch('/tags').then(r => r.json()),
]);

Common Bugs

Forgetting await

The most frequent async bug. if (user.isAdmin) where user is a Promise silently passes because Promises are truthy objects. TypeScript catches many of these; ESLint's no-floating-promises catches more.

Top-level await

Allowed in ES modules and modern Node, but not in CommonJS. In a CJS file you must wrap the call in an async IIFE: (async () => { await work(); })();. Even in ESM, top-level await delays the entire module graph — use it sparingly.

Mixing patterns

Wrapping an async function's result in new Promise((resolve) => ...) is a classic anti-pattern (the explicit Promise construction antipattern). If a function already returns a Promise, just return it. Only build a new Promise when adapting a callback API.

Error propagation

An await inside a .then() callback, or a thrown error inside a Promise executor that runs async work, will be lost. Errors thrown inside async functions become Promise rejections — an unhandled rejection can crash Node processes (default in Node 15+).

Sequential when you meant parallel

A for loop with await inside runs one at a time. If the iterations are independent, build the array and pass it to Promise.all instead.

The Bottom Line

Reach for async/await first — it's the most readable and the debugger loves it. Drop to raw Promises when you need combinators like Promise.all. Keep callbacks for event-style APIs that fire more than once. All three coexist because each still solves a problem the others don't.

async await promises callbacks callback hell promise.all javascript async error handling top level await

Explore 300+ Free Tools

Utilko has tools for developers, writers, designers, students, and everyday users — all free, all browser-based.