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, orany. Also the natural style for short pipelines where a chain reads better than fourawaitstatements. - 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.