← Back to the library
FoundationsCore · 6 min

One failed background call crashes the Node process

“A fire-and-forget call (an email, an analytics ping) fails and the whole Node process exits, dropping every in-flight request. Elsewhere, the opposite: errors simply never show up in the logs.”

THE PRINCIPLE

Every promise is either awaited, returned, or explicitly handed off with its own catch. A global unhandledRejection hook is a crash reporter, not error handling.

FIRST MOVES

  1. Read the crash stack: it points at the rejected call, not at the line that forgot to await it. Search callers for calls without await, return, or .catch.
  2. Turn on @typescript-eslint/no-floating-promises and no-misused-promises (both in recommended-type-checked) to find the rest statically.
  3. For intentional fire-and-forget work, attach a catch that logs with context, or move it to a queue so failures are retried and visible.
  4. For the silent case, look for process.on("unhandledRejection", () => {}), --unhandled-rejections=warn/none, empty catch blocks, and promise chains a framework never sees.
  5. Keep the crash behavior: log the reason, exit non-zero, and let the supervisor (systemd, Kubernetes, PM2) restart a clean process.

TOOLS: THEN → NOW

A global handler that logs and keeps runningDefault throw mode plus a reporter that logs, flushes, and exits
Code review to spot missing awaitstypescript-eslint no-floating-promises and no-misused-promises (type-aware)
Wrapping every async route in a catch helperExpress 5 and most modern frameworks route rejected handler promises to error middleware

PATTERN SNAPSHOT

app.post("/signup", async (req, res) => {
  const user = await createUser(req.body);
  sendWelcomeEmail(user.email); // floating: a rejection here crashes the process
  res.status(201).json(user);
});

// Intentional fire-and-forget: own the failure where it happens.
sendWelcomeEmail(user.email).catch((err) => logger.error({ err, userId: user.id }, "welcome email failed"));

// Last line of defense: report, then exit so the supervisor restarts cleanly.
process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "unhandled rejection");
  process.exitCode = 1;
  shutdown(); // stop accepting traffic, drain, exit
});

CLOSE THE AI. EXPLAIN THIS.

What does Node do by default when a promise rejects with no handler, and why is a global handler that only logs a worse outcome than a crash?

SOURCES

Guide reviewed