Almost everything interesting a Node.js program does takes time: reading files, querying a database, calling an API, waiting for a timer. Because Node runs your JavaScript on a single thread, it never waits for these operations to finish. Instead it starts them, carries on, and runs your code again when the result is ready. Writing that "run this later" code well is the most important skill in Node.
Node's style has evolved over the years. Early APIs used callbacks: functions you pass in, called with an error or a result. Deeply nested callbacks became hard to read, so JavaScript added promises, objects that represent a future result. Today most code uses async and await, which let asynchronous code read from top to bottom like ordinary code while still being non-blocking.
In this lesson you'll recognise callbacks, use promises and async/await confidently, handle errors properly, run independent tasks in parallel with Promise.all, deal with partial failures, limit how many tasks run at once, add timeouts, and convert old callback APIs to promises.
Callbacks
Node's original APIs take a callback as their last argument, called with an error first, then the result:
import { readFile } from 'node:fs';
readFile('notes.txt', 'utf8', (err, text) => {
if (err) return console.error('Could not read:', err.message);
console.log(text);
});When one step depends on another, callbacks nest inside callbacks, drifting further right with every step. This "callback hell" is why promises took over. You'll still see callbacks in older code and in event listeners.
Promises and async/await
A promise is either pending, fulfilled with a value, or rejected with an error. An async function always returns a promise, and await pauses that function until a promise settles:
import { readFile } from 'node:fs/promises';
async function loadConfig() {
try {
const text = await readFile('config.json', 'utf8');
return JSON.parse(text);
} catch (err) {
console.error('Config problem:', err.message);
return {};
}
}awaitonly pauses the current async function; the rest of the program keeps running.- A rejected promise becomes a thrown error at the
await, so ordinarytry/catchworks. - In ES modules you can use
awaitat the top level of a file. .then()and.catch()do the same job and are common in older code.
Running work in parallel
Awaiting one call after another runs them in sequence. When tasks don't depend on each other, start them together:
| Method | Resolves when | Rejects when |
|---|---|---|
Promise.all(ps) | All succeed, giving an array of results | Any one fails |
Promise.allSettled(ps) | All finish, giving { status, value/reason } for each | Never |
Promise.race(ps) | The first one settles | The first one fails |
Promise.any(ps) | The first one succeeds | All fail |
Starting thousands of tasks at once can overload a database or API, so process large lists in batches or with a small concurrency limit.
Timers, timeouts and promisify
import { setTimeout as sleep } from 'node:timers/promises'gives a promise-based delay:await sleep(500).AbortSignal.timeout(ms)creates a signal that cancels an operation after a time limit;fetchand many Node APIs accept it.util.promisifyturns a callback-style function into one that returns a promise.- An unhandled rejected promise prints an error and, in modern Node, crashes the process, so always await promises or add
.catch().
Node.js Basics Syntax
Async patterns
fs.readFile(path, 'utf8', (err, data) => { if (err) return; });
const data = await fs.promises.readFile(path, 'utf8');
async function load() { try { return await get(); } catch (e) { } }
const [a, b] = await Promise.all([getA(), getB()]);
const results = await Promise.allSettled(tasks);
const fastest = await Promise.race([primary(), backup()]);
import { setTimeout as sleep } from 'node:timers/promises';
await sleep(100);
await fetch(url, { signal: AbortSignal.timeout(3000) });
const readFileAsync = util.promisify(fs.readFile);Node.js Basics Summary
- Callbacks receive (err, result); nesting them leads to hard-to-read code.
- Promises represent future results; async functions return promises and await waits for them.
- Use try/catch around await to handle errors.
- Promise.all runs independent tasks in parallel; allSettled reports every outcome.
- Limit concurrency for large batches; use AbortSignal.timeout and promisify when needed.
Node.js Basics Examples
Each example shows the code, the output it produces and what each part does.
Sequential versus parallel
import { setTimeout as sleep } from 'node:timers/promises';
async function fetchPrice(item, ms) {
await sleep(ms);
return { item, price: item.length * 1.5 };
}
let start = performance.now();
const a = await fetchPrice('mug', 200);
const b = await fetchPrice('lamp', 200);
console.log(`Sequential: ${a.price + b.price} in ${Math.round((performance.now() - start) / 100) * 100} ms`);
start = performance.now();
const results = await Promise.all([fetchPrice('mug', 200), fetchPrice('lamp', 200)]);
const total = results.reduce((sum, r) => sum + r.price, 0);
console.log(`Parallel: ${total} in ${Math.round((performance.now() - start) / 100) * 100} ms`);Sequential: 10.5 in 400 ms
Parallel: 10.5 in 200 msLine-by-line explanation
- Line 4sleep stands in for a slow database or network call.
- Lines 9 to 10Each await waits before the next call starts, so the times add up.
- Line 11Times are rounded to the nearest 100 ms because they vary slightly.
- Line 14Promise.all starts both calls at once and waits for both.
Handling partial failures
async function checkService(name) {
if (name === 'payments') throw new Error('timeout after 3s');
return `${name} ok`;
}
const services = ['web', 'payments', 'search'];
try {
await Promise.all(services.map(checkService));
} catch (err) {
console.log(`Promise.all failed fast: ${err.message}`);
}
const outcomes = await Promise.allSettled(services.map(checkService));
outcomes.forEach((o, i) => {
console.log(o.status === 'fulfilled' ? o.value : `${services[i]} DOWN (${o.reason.message})`);
});
const first = await Promise.any(services.map(checkService));
console.log(`First healthy: ${first}`);Promise.all failed fast: timeout after 3s
web ok
payments DOWN (timeout after 3s)
search ok
First healthy: web okLine-by-line explanation
- Line 2An async function that throws returns a rejected promise.
- Line 9One failure makes Promise.all reject, losing the other results.
- Line 14allSettled waits for every promise and reports each outcome.
- Line 16Each outcome has a status of fulfilled (with value) or rejected (with reason).
- Line 19Promise.any gives the first success and ignores failures.
Batches, timeouts and promisify
import { promisify } from 'node:util';
import { setTimeout as sleep } from 'node:timers/promises';
async function processInBatches(items, size, worker) {
const results = [];
for (let i = 0; i < items.length; i += size) {
const batch = items.slice(i, i + size);
console.log(`Batch: ${batch.join(', ')}`);
results.push(...(await Promise.all(batch.map(worker))));
}
return results;
}
const doubled = await processInBatches([1, 2, 3, 4, 5], 2, async (n) => n * 2);
console.log(doubled);
try {
await sleep(1000, null, { signal: AbortSignal.timeout(50) });
} catch (err) {
console.log(`Gave up: ${err.name}`);
}
function oldStyle(name, callback) {
setImmediate(() => callback(null, `Hello, ${name}`));
}
const modern = promisify(oldStyle);
console.log(await modern('Ada'));Batch: 1, 2
Batch: 3, 4
Batch: 5
[ 2, 4, 6, 8, 10 ]
Gave up: AbortError
Hello, AdaLine-by-line explanation
- Lines 6 to 7The list is split into groups of size items.
- Line 9Each batch runs in parallel, but only one batch at a time.
- Line 18AbortSignal.timeout cancels the slow operation after 50 ms.
- Lines 23 to 25promisify wraps an error-first callback function so it returns a promise.
Common Node.js Basics Mistakes
A missing await
async function save(user) {
db.insert(user); // not awaited: errors are lost and save "finishes" too early
console.log('Saved');
}async function save(user) {
await db.insert(user);
console.log('Saved');
}await inside a loop for independent work
for (const id of ids) {
results.push(await fetchUser(id)); // one at a time, slow
}const results = await Promise.all(ids.map((id) => fetchUser(id)));Mixing callbacks and promises
const text = readFile('a.txt', 'utf8', (err, data) => data); // text is undefinedimport { readFile } from 'node:fs/promises';
const text = await readFile('a.txt', 'utf8');Node.js Basics in Real Projects
Async code in production
A typical API handler awaits a database query, then several independent calls in parallel with Promise.all, such as loading a user's profile, orders and recommendations at once. Health checks use Promise.allSettled to report which dependencies are down. Import jobs process records in batches to avoid overloading the database, and every outgoing HTTP request has a timeout so one slow service can't hang the whole app.
Node.js Basics FAQ
Can I use await inside forEach?
You can write it, but it won't do what you expect. forEach ignores the promise each callback returns, so the loop finishes immediately and the code after it runs before any of the work is done. Use for...of with await to run the steps one at a time, or await Promise.all(items.map(async (item) => ...)) to run them together.
Does async/await make my code run in parallel?
No. await pauses one function until a promise settles while the rest of the program carries on, but two awaits written one after the other still run in sequence. Work runs at the same time only when you start several operations before awaiting them, for example with Promise.all. Your JavaScript itself still runs on a single thread either way.
What happens to an unhandled promise rejection in Node.js?
Since Node 15, a rejected promise that nothing handles crashes the process with the error and its stack trace. That's deliberate: silently ignored failures are much harder to debug. Fix the cause by awaiting the promise inside try/catch or adding .catch(); a process.on('unhandledRejection') handler is only a last-resort place to log the problem.
What is the difference between process.nextTick, setImmediate and setTimeout?
process.nextTick callbacks run as soon as the current code finishes, even before promise callbacks. setImmediate runs on the next turn of the event loop, after waiting I/O has had a chance, and setTimeout(fn, 0) runs after at least a millisecond in the timers phase. In everyday code, prefer promises and setImmediate; heavy use of nextTick can starve the event loop.
How do I run async tasks one after another in a fixed order?
Use a for...of loop with await inside it: each iteration waits for the previous task to finish before starting the next. This is the right choice when each step depends on the one before, or when a service only accepts one request at a time. For independent tasks, running them together with Promise.all is much faster.
Node.js Basics Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the line so the function waits for the result.
const user = findUser(42);
What does an async function always return?
What does this print?
async function f() { return 5; }
console.log(f() instanceof Promise);
What does Promise.all do if one of its promises rejects?
Why process a list of 10,000 API calls in batches rather than with one Promise.all?
Node.js Basics Quiz
5 questions to check what you learned.
In an error-first callback, what is the first argument?
- The result
- The file name
- An error, or null if there was none
- A promise
How do you handle an error from an awaited promise?
- With .then only
- With try/catch around the await
- Errors can't happen
- With process.exit
Which method resolves with the first promise to succeed?
- Promise.any
- Promise.all
- Promise.race
- Promise.allSettled
What does util.promisify do?
- Makes code faster
- Converts an error-first callback function into one that returns a promise
- Creates a timer
- Catches every error
What happens in modern Node when a rejected promise is never handled?
- Nothing
- It's retried
- An error is reported and the process exits
- It's logged and ignored forever