Common Pitfalls and Best Practices for await nodejs in Server-Side Applications

Using await in Node.js without handling promise rejections, parallelizing independent I/O, or offloading CPU-intensive work to worker threads can cause unhandled rejection crashes, severe latency penalties, and event-loop starvation.

The async/await syntax in Node.js simplifies asynchronous programming by making promise-based code read like synchronous operations. However, as implemented in the nodejs/node repository, this abstraction still relies on the underlying event loop, promise microtasks, and non-blocking I/O. Misusing await in server-side applications can lead to production crashes, performance regressions, and resource leaks.

1. Not Awaiting a Promise (Fire-and-Forget)

Forgetting to await a promise causes the operation to run in the background. If the promise rejects, the rejection bubbles up to the unhandled-rejection handler, which by default terminates the process in recent Node.js versions.

Explicitly await every asynchronous operation unless you truly intend fire-and-forget, and in that case deliberately catch errors.

// ❌ Bad: unhandled rejection could crash the process
await db.save(user);          // ← good
sendEmail(user).catch(console.error); // fire‑and‑forget, but errors are swallowed

// ✅ Good: all async work is awaited or safely handled
await db.save(user);
await sendEmail(user); // handled by outer try/catch

Node’s promise-rejection handling can be observed in the core implementation of process.emitWarning and the default unhandledRejection listener in lib/internal/process/promises.js [https://github.com/nodejs/node/blob/main/lib/internal/process/promises.js].

2. Serializing Independent Operations

Using await inside a for…of loop for independent I/O results in a serial execution path, dramatically increasing latency.

Run independent operations in parallel with Promise.all (or Promise.allSettled if you need individual error handling).

// ❌ Serial execution – 3 × network latency
for (const id of ids) {
  const user = await db.findUser(id);
  results.push(user);
}

// ✅ Parallel execution – single round‑trip latency
const results = await Promise.all(ids.map(id => db.findUser(id)));

The Promise.all implementation lives in the core V8 promise infrastructure used by Node.js; the pattern is encouraged throughout the codebase, for example in lib/fs/promises.js where batch file stats are handled via parallel promises [https://github.com/nodejs/node/blob/main/lib/fs/promises.js].

3. Ignoring the Event-Loop Tick

Using await on a function that internally performs a synchronous CPU-heavy task blocks the event loop for the entire duration of the task, negating the benefits of async/await.

Off-load CPU-bound work to a worker thread (worker_threads) or child process, or at least wrap it in setImmediate/process.nextTick to let the loop breathe.

// ❌ CPU‑heavy sync code blocks the loop
await heavyComputation(); // runs synchronously inside the async function

// ✅ Off‑load to a worker thread
const { Worker } = require('worker_threads');
await new Promise((resolve, reject) => {
  const worker = new Worker(path.resolve(__dirname, 'heavy.js'));
  worker.once('message', resolve);
  worker.once('error', reject);
});

The low‑level nextTick queue is implemented in lib/internal/process/next_tick.js [https://github.com/nodejs/node/blob/main/lib/internal/process/next_tick.js], and the worker‑thread API is exposed in lib/internal/worker.js [https://github.com/nodejs/node/blob/main/lib/internal/worker.js].

Example: Worker-Thread Off-loading of CPU-Heavy Task

Here is a complete implementation showing the worker file and the service wrapper:

// file: src/worker/heavy.js
const { parentPort } = require('worker_threads');

function heavyComputation(data) {
  // …CPU‑intensive work…
  return result;
}

parentPort.on('message', data => {
  const result = heavyComputation(data);
  parentPort.postMessage(result);
});
// file: src/service/calculator.js
const { Worker } = require('worker_threads');
const path = require('path');

async function computeAsync(payload) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.resolve(__dirname, '../worker/heavy.js'));
    worker.once('message', resolve);
    worker.once('error', reject);
    worker.postMessage(payload);
  });
}

// Usage in an async request handler
app.post('/compute', asyncHandler(async (req, res) => {
  const result = await computeAsync(req.body);
  res.json({ result });
}));

Core reference: lib/internal/worker.js demonstrates the low‑level worker‑thread API used above [https://github.com/nodejs/node/blob/main/lib/internal/worker.js].

4. Improper Error Propagation in Express/Koa Middleware

Forgetting to next(err) or throw inside async route handlers leads to silent request hangs, because Express does not automatically catch rejected promises.

Either return the promise from the middleware (Express 4 + 5 does this automatically) or wrap the handler with a helper that forwards errors.

// ❌ Missing error handling – request never resolves
app.get('/users/:id', async (req, res) => {
  const user = await db.find(req.params.id); // if rejects → hanging request
  res.json(user);
});

// ✅ Using a wrapper (or Express 5 style)
const asyncHandler = fn => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.find(req.params.id);
  res.json(user);
}));

Express integration is demonstrated in the node:test harness (test/parallel/test-async-hooks.js) where async hooks are used to track promise lifecycles [https://github.com/nodejs/node/blob/main/test/parallel/test-async-hooks.js].

Example: Safe Express Route with Parallel DB Calls

Combine error handling with parallel execution for optimal performance:

// file: src/routes/user.js
const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get(
  '/users/:id',
  asyncHandler(async (req, res) => {
    const { id } = req.params;

    // Parallel DB queries – one for profile, one for settings
    const [profile, settings] = await Promise.all([
      db.users.getProfile(id),
      db.users.getSettings(id),
    ]);

    res.json({ profile, settings });
  })
);

Relevant Node core code: lib/internal/process/promises.js for unhandled rejection handling, and test/parallel/test-async-hooks.js for async‑hook tracking.

5. Overusing Top-Level Await in Libraries

Placing await at the top level of a CommonJS module forces the entire module load to become asynchronous, which Node.js cannot natively handle. In ES modules it works, but it blocks all consumers of the module until the promise resolves.

Keep module initialization synchronous; expose an async init() function that callers invoke explicitly.

// ❌ Bad: top‑level await in a library
await initDatabase(); // forces every require to wait

// ✅ Good: explicit init
let db;
module.exports.init = async () => {
  db = await initDatabase();
};
module.exports.query = (...args) => db.query(...args);

Node’s ES‑module loader handles top‑level await in lib/internal/modules/esm/loader.js [https://github.com/nodejs/node/blob/main/lib/internal/modules/esm/loader.js], but most third‑party libraries still follow the explicit‑init pattern for compatibility.

6. Unhandled Promise Rejections Across Process Boundaries

Child processes or workers that reject a promise without a handler cause the parent to receive an 'error' event rather than a normal promise rejection, leading to uncaught exceptions.

Always attach 'error' listeners to child processes and workers, and propagate errors using IPC or promise‑based wrappers.

const { fork } = require('child_process');
const child = fork('./worker.js');

child.on('error', err => {
  console.error('Child process error:', err);
  // decide whether to restart, exit, etc.
});

The core implementation of child‑process error forwarding lives in lib/internal/child_process.js [https://github.com/nodejs/node/blob/main/lib/internal/child_process.js].

7. Mixing Callback-Style APIs with await

Directly await-ing a function that still expects a callback (e.g., fs.readFile) returns undefined and leaves the operation incomplete.

Use the promise‑based counterparts (fs.promises.*) or promisify the callback API with util.promisify.

const { readFile } = require('fs').promises; // ✅ Promise API
const data = await readFile('config.json', 'utf8');

 // or
const { promisify } = require('util');
const readFileCb = promisify(require('fs').readFile);
const data = await readFileCb('config.json', 'utf8');

The Promise‑based file API is defined in lib/fs/promises.js [https://github.com/nodejs/node/blob/main/lib/fs/promises.js], which is a thin wrapper around the underlying libuv calls.

8. Forgetting to Close Asynchronous Resources

Opening a DB connection, file handle, or network socket inside an awaited block and never closing it can leak resources, especially when errors occur.

Use try…finally (or using‑style helpers) to guarantee cleanup.

const client = await db.connect();
try {
  await client.query('SELECT * FROM users');
} finally {
  await client.end(); // always executed
}

Resource cleanup patterns are illustrated in Node’s own stream/promises module (lib/stream/promises.js) where the pipeline helper automatically closes streams on error [https://github.com/nodejs/node/blob/main/lib/stream/promises.js].

Example: Proper Cleanup of a File Stream

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

async function pipeToResponse(filePath, res) {
  const src = createReadStream(filePath);
  try {
    await pipeline(src, res); // automatically closes on success/failure
  } catch (err) {
    console.error('Stream error:', err);
    res.status(500).end('Internal Server Error');
  }
}

Implementation reference: lib/stream/promises.js provides the pipeline helper that ensures streams are closed correctly [https://github.com/nodejs/node/blob/main/lib/stream/promises.js].

Summary

  • Always await or handle promises to prevent unhandled rejection crashes, as Node.js terminates processes by default when promises reject without handlers in lib/internal/process/promises.js.
  • Parallelize independent I/O with Promise.all instead of serial await loops to minimize latency.
  • Off-load CPU-intensive work to worker_threads to avoid blocking the event loop, utilizing the API defined in lib/internal/worker.js.
  • Wrap async middleware in Express/Koa with error-forwarding helpers to prevent hanging requests.
  • Avoid top-level await in CommonJS libraries to prevent module loading issues; use explicit init() functions instead.
  • Attach error listeners to child processes and workers to catch cross-boundary rejections handled in lib/internal/child_process.js.
  • Use promisified APIs (fs.promises, util.promisify) instead of await-ing callback-style functions.
  • Guarantee resource cleanup with try…finally or stream/promises.pipeline to prevent descriptor leaks.

Frequently Asked Questions

What happens if I forget to await a promise in Node.js?

If you invoke an async function but omit the await keyword, the function returns a pending promise immediately and continues execution. If that promise later rejects, Node.js emits an unhandledRejection event. Since Node.js 15+, the default behavior terminates the process to prevent silent failures. You can observe this logic in lib/internal/process/promises.js.

How do I run multiple database queries in parallel with await nodejs?

Use Promise.all to execute independent asynchronous operations concurrently. Map your inputs to an array of promises and await the aggregated promise. This approach reduces total latency from the sum of individual operations to the duration of the slowest single operation, as demonstrated in lib/fs/promises.js where batch operations are handled in parallel.

Should I use top-level await in my Node.js library?

Avoid top-level await in CommonJS modules because Node.js cannot asynchronously load them, which forces consumers to refactor their entire application. In ES modules, top-level await is supported via lib/internal/modules/esm/loader.js, but it blocks module evaluation until the promise settles. For library code, export an explicit init() function instead to give consumers control over initialization timing.

When should I use worker threads with await nodejs?

Off-load CPU-intensive calculations—such as image processing, complex mathematical operations, or large data transformations—to worker_threads when the task would otherwise block the event loop for milliseconds or longer. The lib/internal/worker.js implementation allows you to await messages from workers using promise wrappers, keeping your main thread responsive to I/O events.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →