TypeScript Gotcha #3: The Silent Promise Error That Kills Node.js Services
Meta: "Unhandled promise rejections in TypeScript crash Node.js silently. Here's how to find them, fix them, and stop new ones shipping to production." (158 chars)
Y
our Node.js service crashes at 2AM. No stack trace. No error in your logging pipeline. Just a process exit with code 1 and a gap in your uptime chart.The culprit is usually the same thing: a promise that rejected and had nowhere to go.
TypeScript doesn't protect you from this by default. You can have full strict mode enabled, zero any in your codebase, a well-typed API layer and still ship unhandled promise rejections that take down your service under load. The type system can't see what you didn't await. It can't warn you about a .catch() you forgot. And Node.js, for most of its history, would let the process limp along after a rejection before eventually crashing. Since Node 15, it exits immediately. If you're still on an older runtime, you're getting the slower, harder-to-diagnose version of the same problem.
Here's what to look for and how to close the gap.
How Node.js Handles Unhandled Rejections
A promise rejects. If nothing handles that rejection no await in a try/catch, no .catch() on the chain Node.js emits an unhandledRejection event. On Node 15+, that event triggers process.exit(1) by default. Your service dies.
On older Node versions the behavior is softer: a deprecation warning in stderr and continued execution. That sounds better. It isn't. It means your rejected promise is gone the error swallowed and the code that depended on it continues running with undefined state. Corrupted data, failed writes that look like success, request handlers that return 200 after something went wrong internally.
The crash on Node 15+ is actually the honest version. It forces you to find the problem. The silent continuation is how bugs hide in production for months.
The Three Patterns That Cause This
Unawaited async calls in route handlers. The most common one. A handler calls an async function, doesn't await it, and returns a response before the async work completes. If the async call rejects after the response is sent, there's nothing to catch it.
Untitled1app.post('/orders', (req, res) => {2 processOrder(req.body); // no await, no catch3 res.status(202).json({ status: 'accepted' });4});
TypeScript won't flag this. processOrder returns a Promise<void>. The compiler sees a valid expression statement. The promise floats off into nothing.
The fix: make the handler async and await the call. Or attach .catch() explicitly if fire-and-forget is intentional but then handle the error inside the catch, don't suppress it.
Promise chains with missing .catch(). Older patterns before async/await became standard. A chain of .then() calls, none of them with a .catch() at the end. Any rejection in the chain propagates to an unhandled rejection.
Untitled1fetchUser(id)2 .then(user => fetchOrders(user.id))3 .then(orders => sendEmail(orders)); // if sendEmail rejects, nothing catches it
Audit these chains. Either add .catch(), or rewrite as async/await inside a try/catch block. The async/await version is almost always clearer.
async functions called without await at the call site, especially in loops. This one shows up most often when engineers retrofit async functions into synchronous code paths.
Untitled1const results = items.map(item => processItem(item)); // returns Promise[]2// results is an array of unresolved promises, not values
The intent was to process items. The actual behavior is creating an array of pending promises and moving on. If any processItem call rejects, it rejects into the void. Use Promise.all() or Promise.allSettled() depending on whether one failure should stop the rest.
TypeScript Can't Catch This But ESLint Can
The @typescript-eslint/no-floating-promises rule flags any promise expression that isn't awaited, returned, or explicitly handled with .catch(). Enable it in your ESLint config and it catches the route handler pattern above at write time, before it reaches CI.
Untitled1{2 "rules": {3 "@typescript-eslint/no-floating-promises": "error",4 "@typescript-eslint/no-misused-promises": "error"5 }6}
no-misused-promises is the companion rule. It catches cases where a function returning a promise is used in a context expecting a void callback the most common in Express middleware and event listeners, where the type signature accepts a sync function and you hand it an async one.
Untitled1// no-misused-promises catches this2router.get('/users', async (req, res) => {3 const users = await getUsers();4 res.json(users);5});
Wait that example looks fine. It is fine at runtime if getUsers() resolves. But if it rejects, Express's error handling doesn't know to catch an async error. You need either an explicit try/catch wrapping the handler body, or an async wrapper utility:
Untitled1const asyncHandler = (fn: RequestHandler) => (req: Request, res: Response, next: NextFunction) => {2 Promise.resolve(fn(req, res, next)).catch(next);3};45router.get('/users', asyncHandler(async (req, res) => {6 const users = await getUsers();7 res.json(users);8}));
This is the pattern Express doesn't include by default and that most teams discover the hard way. The Biome linter won't catch this specific Express case the Biome setup guide covers what it does and doesn't handle so ESLint's async rules are still worth running alongside it.
The Part That Catches Everyone Off Guard
Top-level await introduces a new version of this problem.
With ES modules and top-level await, you can write:
Untitled1// server.ts2const config = await loadConfig();3startServer(config);
If loadConfig() rejects, Node throws an unhandled rejection during startup. Your process exits before the server binds. That's the obvious case.
The subtle one: top-level side effects in modules that run during import. A database connection that establishes itself when the module loads, using an async function called without await because you're not inside an async context. The module evaluates, the connection attempt fires, your code continues, and sometime later the connection fails with no handler attached.
Production Node.js services should have a global handler as a last resort, not as the primary strategy:
Untitled1process.on('unhandledRejection', (reason, promise) => {2 logger.error({ reason, promise }, 'Unhandled rejection this should not happen');3 process.exit(1);4});
The explicit process.exit(1) here is deliberate. You want to know the process died because of an unhandled rejection, not let it drift in an unknown state. Pair this with your process manager restarting the service and your logging pipeline capturing the error before exit. This is consistent with how Go services handle unrecovered panics the Go graceful shutdown post covers the parallel pattern for comparison.
What a Real Incident Looks Like
A logistics startup running on Node 18 had a webhook handler that processed incoming delivery status updates. The handler parsed an event, updated the database, and queued a push notification. Worked fine in testing. In production, occasionally maybe one in two hundred requests the notification queue call would reject because the queue service was briefly unavailable.
The handler returned 200 before the queue call finished. The rejection had no handler. Node 18 caught it, emitted unhandledRejection, process exited. Their load balancer marked the instance unhealthy, spun up a replacement, and the new instance handled traffic until the next rejection. Their uptime dashboard showed 99.97%. Their error logs showed nothing useful. Their delivery notifications were quietly dropping.
They found it three weeks later when a driver complained enough times that someone looked at raw database records versus sent notifications. The gap was several thousand events.
The fix was twelve lines: make the handler async, wrap the body in try/catch, push the notification attempt into a background job with its own retry queue rather than calling the external service inline. The real fix was the architecture don't make a real-time HTTP handler wait on an external service. The immediate fix was the unhandled rejection.
FAQ
Q: Does Promise.allSettled() protect against unhandled rejections?
A: Yes, in the sense that it collects rejections rather than letting them propagate. Each rejection appears as a { status: 'rejected', reason: ... } entry in the result array. You still need to check the results and handle failures explicitly allSettled doesn't suppress errors, it routes them to a place you can inspect.
Q: Will the no-floating-promises ESLint rule cause too many false positives?
A: Some, especially in test files and scripts where fire-and-forget is intentional. Add // eslint-disable-next-line @typescript-eslint/no-floating-promises where you genuinely mean it and document why. Keep it rare. If you're disabling it in application code, that's a signal the code needs restructuring.
Q: We're on Node 14 and the service doesn't crash on rejection. Should we upgrade? A: Yes. Node 14 is end of life. But before upgrading, run the ESLint async rules first you'll surface the rejections that are currently being swallowed. Upgrading without fixing them first means your service will start crashing on the same bugs it currently hides.
Q: How do I retroactively find unhandled rejections in an existing codebase?
A: Three ways. One: add the global process.on('unhandledRejection') handler with logging and let production tell you where they are. Two: run the ESLint async rules and work through the list. Three: search for .then( without .catch( in the same chain using a code search tool crude but effective for finding the old-style chains quickly.
Q: Is async/await always safer than promise chains?
A: For error handling, yes try/catch is explicit and harder to forget than .catch(). But async/await has its own failure mode: forgetting to await an async call, which is exactly the floating promise problem. The ESLint rule catches both patterns. The TypeScript project setup guide covers how to configure the full ruleset from the start.
Promise errors are quiet until they aren't. Then they take down a process, and you spend a morning reading logs that tell you nothing because the error was never captured.
Fix the patterns. Enable the lint rules. Add the global handler as a backstop. In that order.
Internal links used:
- Biome setup guide linter coverage context
- Go graceful shutdown post parallel pattern across languages
- TypeScript project setup guide full ESLint config context
External links used:
- None required
Word count: ~1,510