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.
Loops and await
Section titled “Loops and await”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
forEachignores the callback’s return value, so the Promise is thrown away. The function returns0before any user is saved.- Worse: if
saveUserthrows, the outertry/catchdoes 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_PROFILEPrefer
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 msconst orders = await getOrders(id); // 100 ms, doesn't depend on user// total ~202 msPrefer
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);// total ~101 msWhy
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 failsawait 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 twicePrefer
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 channelsWhy
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.
Errors nobody catches
Section titled “Errors nobody catches”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 1Prefer
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.
Swallowing errors and losing the trail
Section titled “Swallowing errors and losing the trail”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
nullalready means “not found”. Folding “the DB is down” intonullturns 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 === undefinedtry { 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 callbackPrefer
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.
Quick checklist
Section titled “Quick checklist”- No
asynccallbacks inforEach, and nomapwithout aPromise.all. - Inside
try, every Promise isreturn await/await. - Consecutive independent
awaits are combined withPromise.all(small counts). -
allvsallSettledis a deliberate choice. - Every non-awaited Promise has a
.catch(or goes through a queue). - No
catchreturningnull/[]that hides infrastructure errors. - Only
throwErrorobjects; wrapping always includes{ cause }. - No
new Promise(async ...), no callbacks invoked in a.thenfollowed by a.catch.
Next: Numbers, dates, strings.