Repository navigation
expressIntegration: layer spans end when next() returns instead of when it is called, piling up finish listeners (MaxListenersExceededWarning) and inflating middleware span durations #24853
Description
Is there an existing issue for this?
- I have checked for existing issues https://github.com/getsentry/sentry-javascript/issues
- I have reviewed the documentation https://docs.sentry.io/
- I am using the latest SDK release https://github.com/getsentry/sentry-javascript/releases
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/node
SDK Version
11.1.0 (also 11.0.0; not present on 10.75.0)
Framework Version
Express 5.2.1 (router 2.x) and Express 4.22.3, Node 24.18.1
Link to Sentry event
N/A. The symptom is a Node process warning plus span timings, not a captured event.
Reproduction Example/SDK Setup
instrument.mjs
import * as Sentry from '@sentry/node';
export const spans = [];
Sentry.init({
dsn: 'https://[email protected]/0',
tracesSampleRate: 1.0,
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSendSpan(span) {
spans.push(span);
return span;
},
});app.mjs
import http from 'node:http';
import express from 'express';
import { spans } from './instrument.mjs';
process.on('warning', warning => console.log(`${warning.name}: ${warning.message}`));
const app = express();
// Ten ordinary synchronous middleware (request id, helmet, cors, body parsers, loggers, ...)
for (let i = 0; i < 10; i++) {
app.use(function syncMiddleware(_req, _res, next) {
next();
});
}
app.get('/test', (_req, res) => {
console.log(`'finish' listeners on the response inside the handler: ${res.listenerCount('finish')}`);
const until = Date.now() + 50; // 50ms of synchronous work in the handler
while (Date.now() < until);
res.json({ ok: true });
});
const server = app.listen(0, () => {
http.get(`http://127.0.0.1:${server.address().port}/test`, res => {
res.resume().on('end', () => {
setTimeout(() => {
const first = spans.find(span => span.attributes?.['express.type'] === 'middleware');
const ms = (first.end_timestamp - first.start_timestamp) * 1000;
console.log(`duration of the first (no-op) middleware span: ${ms.toFixed(1)}ms`);
server.close();
}, 100);
});
});
});Steps to Reproduce
npm i @sentry/[email protected] [email protected]node --import ./instrument.mjs app.mjs
Expected Result
Each layer's span ends, and its finish listener is removed, when the layer calls next(). That is how v10's OpenTelemetry-based Express instrumentation behaved. The number of finish listeners stays constant no matter how many layers a request passes through, and a middleware span measures only that middleware.
Actual Result
'finish' listeners on the response inside the handler: 12
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 finish listeners added to [ServerResponse]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
duration of the first (no-op) middleware span: 50.7ms
- Every request whose synchronous middleware/router chain is about 9 or more layers deep logs one
MaxListenersExceededWarning. In our production Express 5 API that is one warning every few seconds, which our platform surfaces as error-level logs. - It also happens for unsampled requests:
tracesSampleRate: 0produces the same warning. - Express 4.22.3 is affected the same way (14 listeners in the same repro).
- The listeners are released when the response finishes, so this is not a real memory leak. The problems are the warning noise and inflated span durations.
Additional Context
Root cause
getSpanForLayeraddsres.once('finish', onFinish)for every traced layer (instrumentation.ts L332-L338). The comment there expects the helper to end the span, and drop the listener, "whennextis called".bindTracingChannelToSpanactually ends the span onasyncEnd(tracing-channel.ts L197-L203). For theCallbackkind,asyncEndis published only after the callback returns.- In Express,
next()runs the rest of the chain synchronously. It only returns after every downstream layer has run up to its first async hop. So every layer on that synchronous chain holds a live span and afinishlistener at the same time: one per middleware and router, plus the route handler. That passes Node's default limit of 10. - The same cause makes each middleware span include the synchronous work of every layer after it. In the repro, a no-op middleware reports 50.7ms because the handler does 50ms of synchronous work.
- The rest of the integration already treats
asyncStartas the momentnextis called, before the downstream layer runs (instrumentation.ts L97-L108). The orchestrion config also says the transform "ends the traced operation whennextis invoked" (config/express.ts L11-L13). Ending onasyncEndtherefore looks unintended.
Suggested fix
End the layer span in the existing asyncStart subscriber:
channel.subscribe({
start: NOOP,
asyncEnd: NOOP,
end: NOOP,
error: data => captureLayerError(data, options.shouldHandleError),
- asyncStart: popLayerPathForLayer,
+ asyncStart: data => {
+ popLayerPathForLayer(data);
+ endLayerSpan(data);
+ },
});/** End a layer's span when it hands off via `next`, before the downstream layer runs. */
function endLayerSpan(data: HandleChannelContext): void {
const span = data._sentrySpan;
if (!span) {
return;
}
data._sentryCleanup?.();
span.end();
}The helper's later asyncEnd then does nothing: removeListener on an already-removed listener is a no-op, and span.end() is idempotent. next(err) still marks the span as errored, because the channel publishes error before asyncStart.
I applied this change to the published 11.1.0 build and re-ran the repro:
'finish' listeners on the response inside the handler: 2
duration of the first (no-op) middleware span: 0.1ms
Other behaviour I checked with the patch, compared against the unpatched build:
- async middleware (
setTimeout(next, 20)) - fall-through routers mounted on the same prefix
next(err): the span status stayserror- transaction and route names (
GET /api/items/:id)
All are unchanged, apart from span durations and end order now being correct.
Workarounds until a fix ships:
expressIntegration({ ignoreLayersType: ['middleware', 'router'] })- leaving
tracesSampleRateunset
PR
I'm happy to open a PR with this change and a node-integration-test.
Metadata
Metadata
Assignees
Projects
- StatusShow more project fieldsNo status
Activity
linear-code commentedon Sep 29, 2026
JS-3851
nicohrubec commentedon Oct 1, 2026
Hey @diobriggs, thanks for reporting and the PR. Sounds reasonable to me. I'll get the PR reviewed.
nicohrubec commentedon Oct 1, 2026
Merged, we'll try to get this out today.
github-actions commentedon Oct 1, 2026
A PR closing this issue has just been released 🚀
This issue was referenced by PR #24854, which was included in the 11.2.0 release.
fix(node): End Express layer spans when `next` is called (getsentry#2…