Lesson 5 of 7
Async Patterns
Learn the common patterns for asynchronous code in Node: , /await, running tasks in parallel, timeouts and retries. They keep your program responsive while it waits on files and networks.
// The callback era (still exists in legacy code)
import { readFile } from 'fs'
readFile("data.txt", "utf-8", (err, data) => {
if (err) { console.error(err); return }
console.log(data)
})
// Promises — cleaner than callbacks
import { readFile as readFilePromise } from 'fs/promises'
readFilePromise("data.txt", "utf-8")
.then(data => console.log(data))
.catch(err => console.error(err))
// async/await — easiest to read
async function readAndLog(path) {
try {
const data = await readFilePromise(path, "utf-8")
console.log(data)
} catch (err) {
console.error("Failed:", err.message)
}
}
// Promise.all — run multiple async operations in parallel
async function readMultiple(paths) {
const contents = await Promise.all(paths.map(p => readFilePromise(p, "utf-8")))
return contents
}
// Promise.allSettled — don't fail fast on errors
async function readSafe(paths) {
const results = await Promise.allSettled(paths.map(p => readFilePromise(p, "utf-8")))
return results.map((r, i) => ({
path: paths[i],
content: r.status === "fulfilled" ? r.value : null,
error: r.status === "rejected" ? r.reason.message : null,
}))
}// Concurrency control — don't fire 10,000 requests at once
async function processInBatches(items, batchSize, asyncFn) {
const results = []
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize)
const batchResults = await Promise.all(batch.map(asyncFn))
results.push(...batchResults)
console.log(`Processed ${Math.min(i + batchSize, items.length)}/${items.length}`)
}
return results
}
// Timeout wrapper
function withTimeout(promise, ms, message = "Timeout") {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(message)), ms)
)
return Promise.race([promise, timeout])
}
// Retry with exponential backoff
async function retry(fn, { attempts = 3, baseDelay = 100 } = {}) {
let lastError
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (err) {
lastError = err
if (i < attempts - 1) {
const delay = baseDelay * 2 ** i + Math.random() * 100
await new Promise(r => setTimeout(r, delay))
}
}
}
throw lastError
}
// Usage
const data = await retry(
() => fetch("https://api.example.com/data").then(r => r.json()),
{ attempts: 3, baseDelay: 200 }
)Common question
Is Node.js slow because it only uses one thread?
Node runs your JavaScript on one thread with an event , while the platform underneath does I/O asynchronously on a pool of threads you never see. When an operation finishes, its is queued and the loop runs it. This suits servers, whose work is mostly waiting on other machines.
The practical rule is to keep the loop free: use the promise-based APIs, never call the Sync versions while handling a request, long computations into chunks or move them to a worker, and measure event loop delay when times go up. In Node, one blocking line is enough to lose your concurrency.
Source: The Node.js website describes it as using "an event-driven, non-blocking I/O model that makes it lightweight and efficient".
Common mix-ups
Each of these sounds reasonable. Think about why it is wrong, then open the explanation.
One thread can only serve one request at a time, so Node must be slower than a threaded server.
Since Node is non-blocking, no code can block it.
CPU-heavy work in Node means rewriting the service in another language.
Tip: use the ← and → keys to move between lessons.