DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

Node.js Async Patterns: Callbacks, Promises, async/await, and Promise.all in Practice

Node.js is single-threaded. All I/O (file reads, network requests, database queries) is async — the event loop handles it. Understanding how Node.js processes async operations explains why your code behaves the way it does and how to avoid common pitfalls like callback hell, Promise chains that swallow errors, and unhandled rejections.

The Callback Era

Node.js started with callbacks — the error-first callback pattern:

const fs = require('fs');

fs.readFile('/path/to/file', 'utf8', (err, data) => {
  if (err) {
    console.error('Error:', err);
    return;
  }
  console.log('Data:', data);
});
Enter fullscreen mode Exit fullscreen mode

The first argument to every Node.js callback is the error. If err is non-null, something went wrong. Forgetting to check err before using data is one of the most common Node.js bugs.

Callback hell happens when you nest multiple async operations:

fs.readFile('config.json', 'utf8', (err, config) => {
  if (err) return handleError(err);
  db.query('SELECT * FROM users', (err, users) => {
    if (err) return handleError(err);
    sendEmail(users[0].email, JSON.parse(config).subject, (err) => {
      if (err) return handleError(err);
      console.log('Done');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Callbacks still appear in older Node.js APIs and third-party libraries. util.promisify converts them to Promises:

const { promisify } = require('util');
const readFile = promisify(fs.readFile);

// Now you can use await
const data = await readFile('/path/to/file', 'utf8');
Enter fullscreen mode Exit fullscreen mode

Promises

Promises represent a future value. They have three states: pending, fulfilled, rejected.

// Creating a Promise
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

// Consuming a Promise
delay(1000)
  .then(() => console.log('Done after 1 second'))
  .catch(err => console.error('Error:', err))
  .finally(() => console.log('Always runs'));
Enter fullscreen mode Exit fullscreen mode

Promise chaining:

fetch('/api/users')
  .then(response => {
    if (!response.ok) throw new Error(`HTTP error ${response.status}`);
    return response.json();
  })
  .then(users => users.filter(u => u.active))
  .then(active => console.log('Active users:', active.length))
  .catch(err => console.error('Failed:', err));
Enter fullscreen mode Exit fullscreen mode

Each .then returns a new Promise. The .catch at the end catches any error from any step.

Async/Await

async/await is syntactic sugar over Promises. It makes async code look synchronous:

async function getActiveUsers() {
  const response = await fetch('/api/users');
  if (!response.ok) throw new Error(`HTTP error ${response.status}`);
  const users = await response.json();
  return users.filter(u => u.active);
}

// Call it
getActiveUsers()
  .then(users => console.log('Active:', users.length))
  .catch(err => console.error('Failed:', err));

// Or with await (inside another async function)
const users = await getActiveUsers();
Enter fullscreen mode Exit fullscreen mode

Error handling with try/catch:

async function loadData() {
  try {
    const users = await getActiveUsers();
    const posts = await getPosts(users[0].id);
    return { users, posts };
  } catch (err) {
    console.error('Failed to load data:', err);
    throw err;  // re-throw so the caller can handle it
  }
}
Enter fullscreen mode Exit fullscreen mode

Running Operations in Parallel

A common mistake: accidentally serializing operations that could run in parallel.

// Slow: each awaits before starting the next
async function slow() {
  const users = await fetchUsers();   // wait 200ms
  const posts = await fetchPosts();   // then wait 200ms
  return { users, posts };            // total: ~400ms
}

// Fast: start both, then await both
async function fast() {
  const [users, posts] = await Promise.all([
    fetchUsers(),    // starts immediately
    fetchPosts()     // starts immediately
  ]);
  return { users, posts };            // total: ~200ms
}
Enter fullscreen mode Exit fullscreen mode

Promise.all resolves when all promises resolve, or rejects as soon as any one rejects.

Promise.allSettled — resolves when all settle, regardless of outcome:

const results = await Promise.allSettled([
  fetchUsers(),
  fetchPosts(),
  fetchConfig()
]);

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log('Success:', result.value);
  } else {
    console.error('Failed:', result.reason);
  }
});
Enter fullscreen mode Exit fullscreen mode

Use this when you want all results even if some fail.

Promise.race — resolves/rejects as soon as any one does:

// Timeout pattern
const timeout = (ms) => new Promise((_, reject) =>
  setTimeout(() => reject(new Error('Timeout')), ms)
);

const data = await Promise.race([
  fetchData(),
  timeout(5000)  // fail if fetchData takes > 5 seconds
]);
Enter fullscreen mode Exit fullscreen mode

Processing Arrays Async

Sequential (when order matters or operations depend on each other):

for (const userId of userIds) {
  await updateUser(userId);  // waits for each before starting next
}
Enter fullscreen mode Exit fullscreen mode

Parallel (when operations are independent):

await Promise.all(userIds.map(id => updateUser(id)));
Enter fullscreen mode Exit fullscreen mode

Parallel with concurrency limit (don't overwhelm the API/DB):

async function processWithLimit(items, fn, concurrency = 5) {
  const results = [];
  for (let i = 0; i < items.length; i += concurrency) {
    const chunk = items.slice(i, i + concurrency);
    const chunkResults = await Promise.all(chunk.map(fn));
    results.push(...chunkResults);
  }
  return results;
}

await processWithLimit(users, user => sendEmail(user.email), 10);
Enter fullscreen mode Exit fullscreen mode

Error Handling Gotchas

Unhandled promise rejections crash Node.js (since v15). Always handle errors:

// Bad: no error handling
fetchData().then(process);

// Good: catch errors
fetchData().then(process).catch(handleError);

// Good: with async/await
try {
  const data = await fetchData();
  process(data);
} catch (err) {
  handleError(err);
}
Enter fullscreen mode Exit fullscreen mode

Global handler for unhandled rejections (last resort, don't rely on this):

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection:', reason);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Async functions always return Promises. Forgetting this causes bugs:

// This doesn't work as expected
app.get('/users', async (req, res) => {
  const users = await getUsers();
  res.json(users);
  // What if getUsers() throws? Express won't catch it in older versions.
});

// Safe: wrap with error handler or use an error-handling wrapper
app.get('/users', async (req, res, next) => {
  try {
    const users = await getUsers();
    res.json(users);
  } catch (err) {
    next(err);  // pass to Express error handler
  }
});
Enter fullscreen mode Exit fullscreen mode

Express 5 (currently in beta) automatically catches async errors.

AsyncLocalStorage for Request Context

Track request-scoped data across async boundaries without passing it everywhere:

const { AsyncLocalStorage } = require('async_hooks');
const requestContext = new AsyncLocalStorage();

// Middleware: store context
app.use((req, res, next) => {
  requestContext.run({ requestId: req.id, userId: req.user?.id }, next);
});

// Anywhere in the call stack
function logError(err) {
  const context = requestContext.getStore();
  console.error({ error: err.message, ...context });
}
Enter fullscreen mode Exit fullscreen mode

This avoids threading req through every function call.

Streams for Large Data

For large files or responses, avoid loading everything into memory:

const { pipeline } = require('stream/promises');
const fs = require('fs');
const zlib = require('zlib');

// Stream a large file, compress it, write to disk
await pipeline(
  fs.createReadStream('large-file.csv'),
  zlib.createGzip(),
  fs.createWriteStream('large-file.csv.gz')
);
Enter fullscreen mode Exit fullscreen mode

pipeline handles backpressure and cleans up on error — better than manual .pipe() chaining.

The key insight for Node.js async: the event loop is single-threaded, so await doesn't block other requests — it just schedules the callback and handles other work until the I/O completes. Your server handles thousands of concurrent requests not by multithreading but by never blocking.

Top comments (0)