I assumed that with an error SDK installed, an async Express route that throws would at least give the user a 500. I had never tested it, so I did. The result surprised me.
The short version: on Express 4, an async handler that throws after an await is reported by Sentry, keeps the process alive, and never answers the request. The client just waits.
Everything below was run on 2026-10-16 on a Linux box with Node 20.19.2, Express 4.22.3, Express 5.3.0 and @sentry/node 11.8.0. The "Sentry server" is a throwaway 15-line Node script on localhost, so nothing leaves the machine. Outputs are pasted from the runs, with log noise trimmed.
TL;DR
- Express 4 does not look at the promise your
asynchandler returns. A throw after anawaitbecomes an unhandled rejection, not anext(err)call. - Without an error SDK, Node 20 kills the process. With
@sentry/nodeinitialised, the process survives, the error is reported, and the request hangs. That is the nasty one, because everything looks healthy. - Fix on Express 4: wrap the handler and
.catch(next). Or move to Express 5, where rejected promises go to your error handler on their own. The Express docs describe the Express 5 behaviour in their error handling guide.
The smallest app that reproduces it
// app.js
const express = require('express');
const app = express();
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
if (process.env.SENTRY) {
const Sentry = require('@sentry/node');
global.Sentry = Sentry;
Sentry.init({ dsn: 'http://key@127.0.0.1:9999/1', tracesSampleRate: 0 });
}
app.get('/sync', () => { throw new Error('sync boom'); });
app.get('/async', async () => { await sleep(10); throw new Error('async boom'); });
app.get('/ok', (req, res) => res.send('ok'));
if (process.env.SENTRY) global.Sentry.setupExpressErrorHandler(app);
app.use((err, req, res, next) => {
console.log('[error middleware]', err.message);
res.status(500).send('handled');
});
app.listen(3000);
Two routes that throw. One throws right away. The other throws after a 10 ms await, which is what any real handler does the moment it calls a database.
And the "Sentry server", just enough to see what arrives:
// sink.js
const http = require('http'), zlib = require('zlib');
http.createServer((q, r) => {
const b = [];
q.on('data', c => b.push(c));
q.on('end', () => {
let buf = Buffer.concat(b);
try { buf = zlib.gunzipSync(buf); } catch {}
for (const l of buf.toString().split('\n').filter(Boolean)) {
try {
const j = JSON.parse(l);
if (j.exception) {
const e = j.exception.values[0];
console.log('SINK event:', e.type, e.value, 'mech=', e.mechanism && e.mechanism.type);
}
} catch {}
}
r.end('{}');
});
}).listen(9999);
The test is the same every time: start the app, curl the sync route, curl the async route with -m 5, then curl /ok to see if the server is still alive.
Run 1: Express 4, no SDK
GET /sync -> http=500 curl_exit=0
GET /async -> http=000 curl_exit=52
GET /ok afterwards -> (curl_exit=7)
process: dead
[error middleware] sync boom
/app.js:10
app.get('/async', async () => { await sleep(10); throw new Error('async boom'); });
^
Error: async boom
Node.js v20.19.2
The sync throw gets a clean 500 from my error middleware. The async one never reaches it. Node 20 treats the unhandled rejection as fatal and the whole process exits. The client sees an empty reply (curl exit 52) and the next request is refused (exit 7).
That is loud, at least. Your process manager restarts it and you notice.
Run 2: Express 4, with Sentry
Same app, SENTRY=1:
GET /sync -> http=500 curl_exit=0
GET /async -> http=000 curl_exit=28
GET /ok afterwards -> ok (curl_exit=0)
process: alive
SINK event: Error sync boom mech= auto.middleware.express
SINK event: Error async boom mech= auto.node.onunhandledrejection
Exit code 28 is curl giving up after my 5 second limit. The error was reported. The process did not crash. And the request got nothing.
Two things I noticed in the output:
- The sync error came in through
auto.middleware.express, meaning the Express error handler saw it. The async one came in asauto.node.onunhandledrejection. Sentry caught it, but from the global rejection hook, not from Express. - The app log shows Node's usual "This error originated either by throwing inside of an async function without a catch block" warning, and then nothing else. The process keeps running.
To check this is a real hang and not a slow response, I left one request open for 25 seconds. curl timed out at 25.0 s with no status code, and ss still showed one established connection on port 3000 twenty seconds in. The socket stays open until the client or a proxy gives up.
So the SDK did its job. It reported the error and kept my process alive. What it cannot do is write a response for a request Express no longer knows about. In production that "stays up" behaviour is what turns a crash into a silent hang, and the hang is probably cut off by your load balancer's idle timeout, not by anything in your app. I did not test a proxy here, so I can't tell you what yours does.
Run 3: Express 5
I made a second project with Express 5.3.0 and the same app.js:
GET /sync -> http=500 curl_exit=0
GET /async -> http=500 curl_exit=0
GET /ok afterwards -> ok (curl_exit=0)
process: alive
[error middleware] sync boom
[error middleware] async boom
With Sentry on, both events arrive with mech= auto.middleware.express. The async error goes through my error middleware, the client gets a 500, and nothing hangs.
This matches what the Express docs say: route handlers that return a promise call next(value) when they reject, and async functions always return a promise. No code change needed, only the version.
The fix if you are stuck on Express 4
A small wrapper that hands rejections to next:
const wrap = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/async', wrap(async () => {
await sleep(10);
throw new Error('async boom');
}));
Run on Express 4.22.3 with Sentry initialised:
wrapped express4: http=500 time=0.029606s
SINK event: Error async boom mech= auto.middleware.express
500 in about 30 ms, and the event now comes through the Express error handler, same as the sync case. Sentry's setupExpressErrorHandler has to go after your routes and before your own error middleware, which is where it was in my app.js.
What I would check in your app today
-
Which Express version are you on?
npm ls express. If it says 4.x, every async handler without acatchis a hang waiting for a bad day. -
Grep for handlers that look safe but are not.
app.get('/x', async (req, res) => {with notryand no wrapper. AlsosetTimeoutand event emitter callbacks, which Express never sees on any version. The Express docs say to catch those yourself and callnext(err). - Look at the error source in your tracker. In my run the hung request showed up as an unhandled rejection, not as an Express error. If your tracker has a mechanism or "handled" field, filter for the unhandled rejection ones. Those are the routes without a wrapper.
- Add a response timeout in front of the app. I did not benchmark this, so I will not give you a number. The point is that a hung request should end with a 504 from your proxy in seconds, not a user closing the tab.
- Do not turn off the crash to make the noise stop. Crashing was the better failure mode in Run 1. A restart loop is easier to spot than a silent hang.
Where I run this
I build Epure, a small self-hosted error tracker: one Rust binary and Postgres 16, errors only, and you point the official Sentry SDK at it by swapping the DSN. The results above come from the throwaway sink, not from Epure, so they say nothing about it either way. If you want to see what that DSN swap looks like, the docs have it.
What this does not show
- One Node version (20.19.2), one SDK version. A different
@sentry/nodemajor or Node's--unhandled-rejectionsflag could change the hang. - A single route, tested with
curl. I did not run a real database or a proxy in front. - I read the Express 5 behaviour from the docs and confirmed it with the run above. I did not read the router source.
Which Express version is your production API on right now, and does it have a request timeout in front of it?
Top comments (0)