Answer:
Callbacks are the original async pattern in Node — you pass a function that runs when the operation finishes.
Error-first convention: Node core callbacks take (err, ...results). Always check err first:
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) return handleError(err); // error first
console.log(data);
});
The return on error prevents running the success path.
Callback hell — nesting async steps produces a rightward "pyramid of doom" that's hard to read and error-handle:
getUser(id, (err, user) => {
if (err) return cb(err);
getPosts(user.id, (err, posts) => {
if (err) return cb(err);
getComments(posts[0].id, (err, comments) => {
if (err) return cb(err);
// ...deeper and deeper
});
});
});
Fixes:
- Promises + chaining flatten the nesting.
- async/await makes async code read like synchronous code.
try {
const user = await getUser(id);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
} catch (err) {
handleError(err); // one place for errors
}
Tip: you can wrap a callback API with util.promisify to use it with async/await.
Answer:
States: a Promise is pending, then settles to fulfilled (with a value) or rejected (with a reason). Once settled it never changes.
Chaining: each .then returns a new promise, so you can transform values and funnel all errors to a single .catch:
fetchUser(id)
.then(user => fetchPosts(user.id))
.then(posts => render(posts))
.catch(err => handle(err)) // catches any rejection above
.finally(() => stopSpinner());
Combinators:
| Method | Settles when | Result | Use case |
|---|---|---|---|
Promise.all | all fulfilled, or first rejection | array of values / first error | parallel work, all required |
Promise.allSettled | all settled | array of {status, value/reason} | independent tasks, want every outcome |
Promise.race | first to settle (either way) | that value/reason | timeouts, fastest wins |
Promise.any | first fulfillment, or all rejected | first value / AggregateError | fallbacks/redundancy |
// Fetch three resources in parallel; fail if any fails
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);
// Add a timeout to any promise
const result = await Promise.race([
doWork(),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000)),
]);
Gotchas:
Promise.allis fail-fast: one rejection rejects the whole thing (the others still run but their results are discarded). UseallSettledwhen you need every outcome.- Always attach a
.catch(or wrapawaitin try/catch) — an unhandled rejection can crash the process.
Answer:
async/await lets you write promise-based code that reads sequentially:
async function loadDashboard(id) {
try {
const user = await getUser(id);
const posts = await getPosts(user.id);
return { user, posts };
} catch (err) {
// any rejection above lands here
throw new Error('Failed to load dashboard', { cause: err });
}
}
- An
asyncfunction always returns a Promise. awaitsuspends the function (not the thread) until the awaited promise settles; a rejection throws, caught bytry/catch.
Converting callback APIs to promises:
util.promisifyfor error-first callback functions:
const util = require('util');
const readFile = util.promisify(require('fs').readFile);
const data = await readFile('data.txt', 'utf8');
- Use built-in promise APIs where available:
const fs = require('fs/promises');
const data = await fs.readFile('data.txt', 'utf8');
- Manual wrapping for non-standard callbacks:
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
await delay(1000);
Common pitfalls:
- Accidental serialization: awaiting in a loop runs items one-by-one. Use
Promise.allwithmapfor parallelism. - Missing
await: forgetting it means you get a pending Promise instead of the value, and errors go unhandled. forEach+ async doesn't await — usefor...oforPromise.all(map(...)).
Answer:
EventEmitter (from the events module) is the foundation of Node's event-driven APIs — HTTP servers, streams, sockets, and the process object are all emitters.
Basic usage:
const { EventEmitter } = require('events');
class Order extends EventEmitter {}
const order = new Order();
order.on('paid', (amount) => console.log(`Received $${amount}`));
order.once('shipped', () => console.log('Shipped!')); // fires at most once
order.emit('paid', 99); // triggers the 'paid' listener
order.emit('shipped');
Key methods:
.on(event, fn)/.addListener— subscribe..once(event, fn)— subscribe for a single occurrence..emit(event, ...args)— fire synchronously, in listener registration order..off(event, fn)/.removeListener— unsubscribe (important to avoid leaks).
When to use EventEmitter vs Promises:
- Promise/async-await: a single future result (a DB query, one HTTP request).
- EventEmitter: a stream of events over time (data chunks, connection events, progress updates) — one-to-many, repeated.
Gotchas:
- Listeners run synchronously; a throwing listener can crash unless handled.
- An
'error'event with no listener throws and can crash the process — always handle'error'. - Adding many listeners triggers a memory-leak warning at 10 by default (
emitter.setMaxListeners(n)to adjust); usually the warning means you forgot to remove listeners.
Answer:
Sequential — when each step depends on the previous, or you must limit load:
const results = [];
for (const id of ids) {
results.push(await fetchItem(id)); // one at a time
}
Parallel — when operations are independent, run them together:
// Kick them all off, then wait for all
const results = await Promise.all(ids.map(id => fetchItem(id)));
This can be dramatically faster: three 100ms calls take ~100ms in parallel vs ~300ms sequentially.
Common mistakes:
- Accidental sequencing — awaiting independent calls one by one:
// ❌ Slow: b doesn't need a
const a = await getA();
const b = await getB();
// ✅ Fast:
const [a, b] = await Promise.all([getA(), getB()]);
forEachdoesn't await:
// ❌ Fires all, but the function returns before they finish
items.forEach(async (i) => { await save(i); });
// ✅ Wait for completion
await Promise.all(items.map(i => save(i)));
// ✅ Or truly sequential:
for (const i of items) { await save(i); }
- Unbounded parallelism —
Promise.allover 10,000 items opens 10,000 connections at once. Use a concurrency limit (e.g.,p-limit, or process in batches) to protect downstream services and the libuv thread pool.
Rule of thumb: default to parallel for independent work (Promise.all), sequential only when there's a real dependency or a need to throttle.