Skip to content

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:

  1. pending - initial state , neither fulfilled nor rejected. The async operation hasn't completed yet
  2. fulfilled - the operation completed successfully , the promise has a resolved value
  3. 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 chain
  • Promise.all() for parallel independent operations
  • Promise.allSettled() when you need results from every operation regardless of success
  • Promise.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