async_02 - Promises¶
Callbacks were a nightmare of nested pyramids and forgotten error checks
Promises arrived in ES6 as a first-class solution for async control flow - a standardized way to represent a value that doesn't exist yet but will eventually. A Promise is a returned object that lets you attach callbacks instead of passing them in. The difference is subtle but transformative: you write linear-looking chains instead of nested pyramids
promise states¶
A Promise has exactly three states and can only transition once:
- pending - initial state , neither fulfilled nor rejected. The async operation hasn't completed yet
- fulfilled - the operation completed successfully , the promise has a resolved value
- rejected - the operation failed , the promise has a reason (usually an Error object)
Once a promise settles (fulfilled or rejected) , it's immutable. Calling resolve() after reject() does nothing. The state is locked forever
const promise = new Promise((resolve, reject) => {
// You are in the pending state here
const random = Math.random()
if (random > 0.5) {
resolve('you won the coin flip')
} else {
reject(new Error('you lost the coin flip'))
}
})
// resolve and reject are idempotent - only the first call counts
const stuck = new Promise((resolve) => {
resolve('first')
resolve('second') // silently ignored
reject(new Error('nope')) // also ignored
})
stuck.then(console.log) // logs: 'first'
creating promises¶
Wrap callback-based functions with the Promise constructor
const fs = require('fs')
function readFilePromise(filePath, encoding) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, encoding, (err, data) => {
if (err) {
reject(err) // triggers .catch()
} else {
resolve(data) // triggers .then()
}
})
})
}
// usage
readFilePromise('/etc/hosts', 'utf8')
.then((data) => console.log('hosts:', data))
.catch((err) => console.error('failed:', err.message))
Node provides util.promisify to convert callback-based functions without writing the wrapper manually
const fs = require('fs')
const { promisify } = require('util')
const readFile = promisify(fs.readFile)
const writeFile = promisify(fs.writeFile)
readFile('/etc/hosts', 'utf8')
.then((data) => writeFile('/tmp/hosts.backup', data))
.then(() => console.log('backup created'))
.catch((err) => console.error('operation failed:', err))
Modern Node (v10+) includes fs.promises API - no promisify needed
const fs = require('fs/promises')
async function example() {
const data = await fs.readFile('/etc/hosts', 'utf8')
console.log(data)
}
.then() , .catch() , .finally()¶
These are the three consumer methods on every Promise:
- .then(onFulfilled, onRejected) - handles resolution or rejection. Returns a new Promise for chaining
- .catch(onRejected) - syntactic sugar for
.then(null, onRejected). Handles rejection only - .finally(onFinally) - runs regardless of outcome. No access to the resolved value. Good for cleanup
const fs = require('fs/promises')
fs.readFile('/etc/hosts', 'utf8')
.then((data) => {
console.log('file read successfully')
return data.toUpperCase()
})
.then((upperData) => {
console.log('transformed:', upperData.slice(0, 100))
})
.catch((err) => {
// catches errors from EITHER .then() above
console.error('something went wrong:', err.message)
})
.finally(() => {
// runs no matter what
console.log('operation complete')
})
Important: .then() and .catch() return new Promises. If you return a value from .then() , the next .then() receives that value. If you return a Promise from .then() , the chain waits for it to settle before proceeding. If you don't return anything , the next .then() gets undefined
Promise.resolve('hello')
.then((val) => {
console.log(val) // 'hello'
// no return statement
})
.then((val) => {
console.log(val) // undefined - common mistake
})
promise combinators¶
JavaScript provides four static methods for handling multiple Promises:
Promise.all()¶
Waits for ALL promises to fulfill. If ANY rejects , the entire thing rejects immediately. Results are in the same order as the input array
const fs = require('fs/promises')
async function loadConfigs() {
const promises = [
fs.readFile('config/database.json', 'utf8'),
fs.readFile('config/server.json', 'utf8'),
fs.readFile('config/auth.json', 'utf8'),
]
try {
const [dbConfig, serverConfig, authConfig] = await Promise.all(promises)
return {
db: JSON.parse(dbConfig),
server: JSON.parse(serverConfig),
auth: JSON.parse(authConfig),
}
} catch (err) {
// if ANY file fails , this catches the first rejection
console.error('config load failed:', err.message)
throw err
}
}
Promise.allSettled()¶
Waits for ALL promises to settle (fulfill or reject). Returns an array of objects with status, value, and reason fields. Never rejects - use this when you need results from all operations even if some fail
const urls = ['/api/users', '/api/posts', '/api/comments']
const results = await Promise.allSettled(
urls.map((url) => fetch(url).then((r) => r.json()))
)
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`URL ${index} succeeded:`, result.value)
} else {
console.log(`URL ${index} failed:`, result.reason.message)
}
})
Promise.race()¶
Returns as soon as the FIRST promise settles (fulfill or reject). Use for timeouts
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('operation timed out')), ms)
)
return Promise.race([promise, timeout])
}
async function fetchWithTimeout() {
const result = await withTimeout(fetch('https://api.example.com/data'), 5000)
return result.json()
}
Promise.any()¶
Returns as soon as the FIRST promise FULFILLS. If all reject , returns an AggregateError. Use for fallback patterns where any success is acceptable
async function fastestMirror() {
const mirrors = [
'https://mirror1.example.com/file',
'https://mirror2.example.com/file',
'https://mirror3.example.com/file',
]
try {
const response = await Promise.any(
mirrors.map((url) => fetch(url).then((r) => r.text()))
)
return response
} catch (err) {
// err is an AggregateError - all mirrors failed
console.error('all mirrors failed:', err.errors.length, 'attempts')
throw err
}
}
unhandled promise rejections¶
This is a showstopper security issue
In Node 15+ , unhandled promise rejections cause the process to exit with a non-zero code. In Node 17+ , this behavior is the default. A Promise.then().catch() chain where you forget the .catch() means an error silently disappears until the process crashes
// The server killer
app.get('/user/:id', async (req, res) => {
const user = await db.findUser(req.params.id) // imagine db.findUser rejects
res.json(user)
})
// If `findUser` rejects , the async function returns a rejected promise
// Express doesn't catch it by default
// Your handler throws , but Express doesn't call next(err)
// Node eventually detects the unhandled rejection and kills the process
In Express , wrap async handlers:
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
app.get('/user/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id)
res.json(user)
}))
Or use the express-async-errors package which patches Express to catch async rejections automatically
process-level fallback (last resort)¶
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
// Log to external service , then crash for clean restart
process.exit(1)
})
Never use this as a crutch for forgetting .catch() - it's a safety net , not a design pattern. Future Node versions will treat unhandled rejections as fatal and this handler won't save you
promise anti-patterns¶
The explicit construction anti-pattern¶
Don't create a Promise when you already have one
// BAD - unnecessary wrapper
function readConfig() {
return new Promise((resolve, reject) => {
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) reject(err)
else resolve(data)
})
})
}
// GOOD - use fs.promises or util.promisify
const readConfig = () => fs.promises.readFile('config.json', 'utf8')
Promise.all with independent operations¶
Don't sequence operations that can run in parallel
// BAD - sequential , 3 seconds total
const user = await fetchUser(id)
const posts = await fetchPosts(id)
const comments = await fetchComments(id)
// GOOD - parallel , ~1 second total
const [user, posts, comments] = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchComments(id),
])
Swallowing errors in catch¶
// BAD - error is caught and ignored
try {
await dangerousOperation()
} catch (err) {
// err is silently consumed , good luck debugging
}
// GOOD - at minimum log the error
try {
await dangerousOperation()
} catch (err) {
console.error('operation failed:', err.message)
throw err // re-throw if you can't handle it
}
summary¶
- Promises represent a future value in one of three states: pending , fulfilled , rejected
.then()chains return new Promises - chain them , don't nest them.catch()catches rejections anywhere in the chainPromise.all()for parallel independent operationsPromise.allSettled()when you need results from every operation regardless of successPromise.race()for timeouts and first-to-respond patterns- Unhandled promise rejections crash Node - handle your errors
- Avoid the explicit construction anti-pattern - use existing Promise APIs
prerequisites¶
async_01_async.md - callbacks and async concepts
next -> async_03_async_await.md