Skip to content
Knowledge Hub

Common async and error-handling mistakes in Node.js/TypeScript Intermediate

Estimated reading time: 9 min read

This page collects the async and error-handling mistakes that most often slip through code review. What they share: the code works on the happy path and only blows up when the DB is slow, an external API fails, or load rises.

Every output in the comments is a real run on Node 24.17.

Where does a Promise go after it’s created?

Section titled “Where does a Promise go after it’s created?”

Loading diagram…

Most mistakes below are a variant of the “No” branch: a Promise is created but nobody waits for it, so the error doesn’t arrive where it should.

Using an async callback in forEach

Common

async function importUsers(ids: number[]) {
let saved = 0;
ids.forEach(async (id) => {
await saveUser(id);
saved++;
});
return saved; // 0 — forEach doesn't wait for async callbacks
}

Prefer

async function importUsers(ids: number[]) {
let saved = 0;
for (const id of ids) {
await saveUser(id); // sequential, errors reach the outer try/catch
saved++;
}
return saved; // 3 with ids = [1, 2, 3]
}
// Need parallelism? Use Promise.all(ids.map(saveUser)) with a concurrency limit.

Why

  • forEach ignores the callback’s return value, so the Promise is thrown away. The function returns 0 before any user is saved.
  • Worse: if saveUser throws, the outer try/catch does not catch it. The error becomes an unhandled rejection and the process exits (tested: main: done, no error caught, then a crash).

Returning a Promise inside try without await

Common

async function getProfile(id: string) {
try {
return fetchProfileFromDb(id); // returns the Promise out, not yet settled
} catch {
return DEFAULT_PROFILE; // never runs when the DB fails
}
}
// Result: getProfile throws "db down" instead of returning DEFAULT_PROFILE

Prefer

async function getProfile(id: string) {
try {
return await fetchProfileFromDb(id); // wait for settlement inside the try
} catch {
return DEFAULT_PROFILE; // runs correctly when the DB fails
}
}

Why

try/catch only catches errors that happen while it is running. Without await, the function leaves the try while the Promise is still pending; by the time it rejects, the catch is already behind you. Rule: inside try, always return await.

Awaiting independent work sequentially

Common

const user = await getUser(id); // 100 ms
const orders = await getOrders(id); // 100 ms, doesn't depend on user
// total ~202 ms

Prefer

const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);
// total ~101 ms

Why

Each await pauses the function until it’s done. Writing two independent calls sequentially adds up their latency, making the endpoint twice as slow for no reason. Only parallelize when the count is small and fixed; for long lists, see Unbounded Promise.all.

Promise.all when you need partial results

Common

// Send notifications on 3 channels; 1 channel fails
await Promise.all([sendPush(u), sendEmail(u), sendSms(u)]);
// Promise.all rejected: smtp timeout
// -> no idea whether push/sms were sent, retry all 3 -> user gets the push twice

Prefer

const results = await Promise.allSettled([sendPush(u), sendEmail(u), sendSms(u)]);
// [ 'fulfilled', 'rejected', 'fulfilled' ]
const failed = results
.map((r, i) => ({ r, channel: CHANNELS[i] }))
.filter(({ r }) => r.status === 'rejected');
// retry only the failed channels

Why

Promise.all is fail-fast: it rejects as soon as one Promise rejects, ignoring the results of the rest (they keep running, they aren’t cancelled). Use all when “one failure means total failure”; use allSettled when the tasks are independent and you need to know each outcome.

Fire-and-forget without .catch

Common

async function register(dto: RegisterDto) {
const user = await usersRepo.create(dto);
sendWelcomeEmail(user); // no await, no catch
return user;
}
// SMTP fails -> Error: SMTP 421 ... Node.js v24.17.0, exit code 1

Prefer

async function register(dto: RegisterDto) {
const user = await usersRepo.create(dto);
sendWelcomeEmail(user).catch((err) =>
logger.error({ err, userId: user.id }, 'welcome email failed'),
);
return user;
}
// Better still: push it to a queue/outbox to get retries.

Why

Since Node 15, the default is --unhandled-rejections=throw: a rejected Promise without a handler becomes an uncaught exception and the process exits. One failed email takes down the whole API, cutting off every request in flight. Every Promise that isn’t awaited must have a .catch.

An async executor in new Promise

Common

const config = new Promise(async (resolve) => {
const raw = await readFile(path, 'utf8');
resolve(JSON.parse(raw)); // broken JSON -> throws inside the async executor
});
// The process crashes on an unhandled rejection; with a global handler,
// `config` stays pending forever and requests hang.

Prefer

async function loadConfig(path: string) {
const raw = await readFile(path, 'utf8');
return JSON.parse(raw); // error -> the returned Promise rejects properly
}

Why

An error thrown in a synchronous executor is automatically turned into a rejection by new Promise. But an async executor returns another Promise nobody listens to, so the error goes into that Promise. Once you’re using await, a plain async function is enough; no new Promise needed.

catch, then return null

Common

async function findUser(id: string) {
try {
return await db.user.findUnique({ where: { id } });
} catch {
return null;
}
}
// DB connection lost -> null -> the controller returns 404 "User not found"

Prefer

async function findUser(id: string) {
return db.user.findUnique({ where: { id } }); // null = not found; error = error
}
// Only catch errors you can handle, e.g. Prisma P2002 (unique violation) -> 409.

Why

  • null already means “not found”. Folding “the DB is down” into null turns a 500 incident into a flood of 404s.
  • Alerts based on the 5xx rate stay silent, and clients may even clear caches thinking the user was deleted.

Throwing strings and wrapping errors that lose the cause

Common

if (!order) throw 'order not found'; // e.stack === undefined
try {
await paymentClient.charge(order);
} catch (e) {
throw new Error('Payment failed'); // the original ECONNRESET disappears
}

Prefer

if (!order) throw new NotFoundException(`Order ${id} not found`);
try {
await paymentClient.charge(order);
} catch (e) {
throw new Error(`Payment for order ${order.id} failed`, { cause: e });
}
// console.log prints: Error: Payment ... { [cause]: Error: read ECONNRESET ... }

Why

Throwing a string gives no stack, instanceof Error is false, and NestJS’s exception filter treats it as an unknown error. Wrapping an error without passing cause leaves only a generic message in the logs. The cause option exists since ES2022; Node’s console.log/util.inspect print the whole [cause] chain.

Mixing callbacks and Promises

Common

function getUser(id: number, cb: (err: Error | null, user?: User) => void) {
findUser(id)
.then((user) => cb(null, user))
.catch((err) => cb(err)); // also catches errors thrown from inside cb
}
// The callback throws -> .catch calls cb a second time:
// bad cb call 1 user=An
// bad cb call 2 err=bug in callback

Prefer

import { callbackify } from 'node:util';
const getUser = callbackify(findUser); // cb is called exactly once
// An error thrown in cb becomes an uncaughtException, it doesn't come back to cb.
// Best: move the caller fully to async/await.

Why

A .catch placed after .then(cb) also catches errors thrown by the callback itself. The callback gets called twice: the response is sent twice (ERR_HTTP_HEADERS_SENT), or a transaction commits and then rolls back. util.callbackify calls the callback outside the Promise chain, so this loop can’t happen.

  • No async callbacks in forEach, and no map without a Promise.all.
  • Inside try, every Promise is return await / await.
  • Consecutive independent awaits are combined with Promise.all (small counts).
  • all vs allSettled is a deliberate choice.
  • Every non-awaited Promise has a .catch (or goes through a queue).
  • No catch returning null/[] that hides infrastructure errors.
  • Only throw Error objects; wrapping always includes { cause }.
  • No new Promise(async ...), no callbacks invoked in a .then followed by a .catch.

Next: Numbers, dates, strings.