denoland/deno · error · Error
ERR_HTTP2_TRAILERS_ALREADY_SENT
ERR_HTTP2_TRAILERS_ALREADY_SENT
Error message
Trailing headers have already been sent
What it means
sendTrailers can be invoked exactly once per stream: after a successful call, kSentTrailers is set and a second call throws ERR_HTTP2_TRAILERS_ALREADY_SENT. Importantly, onStreamTrailers automatically calls sendTrailers({}) when the 'wantTrailers' event has no listeners, so a late manual send after that automatic empty-trailers frame also counts as a duplicate.
Source
Thrown at ext/node/polyfills/http2.ts:2469
return;
}
if (!this[kState].didRead) {
this._readableState.readingMore = false;
this[kState].didRead = true;
}
if (!this.pending) {
FunctionPrototypeCall(streamOnResume, this);
} else {
this.once("ready", streamOnResume);
}
}
sendTrailers(headers) {
if (this.destroyed || this.closed) {
throw new ERR_HTTP2_INVALID_STREAM();
}
if (this[kSentTrailers]) {
throw new ERR_HTTP2_TRAILERS_ALREADY_SENT();
}
if (!this[kState].trailersReady) {
throw new ERR_HTTP2_TRAILERS_NOT_READY();
}
assertIsObject(headers, "headers");
headers = ObjectAssign({ __proto__: null }, headers);
debugStreamObj(this, "sending trailers");
this[kUpdateTimer]();
const headersList = buildNgHeaderString(
headers,
assertValidPseudoHeaderTrailer,
this.session[kStrictSingleValueFields],
);
this[kSentTrailers] = headers;View on GitHub (pinned to 9ad36f7a2c)
Solutions
- Register the 'wantTrailers' listener synchronously, before ending the stream, whenever you respond with waitForTrailers: true
- Send trailers from exactly one place — inside the 'wantTrailers' listener
- Track sent state: if (stream.sentTrailers) return; (the polyfill exposes the flag) before calling sendTrailers
Example fix
// before
stream.respond(headers, { waitForTrailers: true });
stream.end(data);
stream.sendTrailers({ 'x-total': '42' }); // may race the auto empty-trailers send
// after
stream.on('wantTrailers', () => {
stream.sendTrailers({ 'x-total': '42' }); // single send site
});
stream.respond(headers, { waitForTrailers: true });
stream.end(data); Defensive patterns
Strategy: try-catch
Validate before calling
if (!stream.sentTrailers) {
stream.sendTrailers(headers);
} Type guard
const canSendTrailers = (s: Http2Stream): boolean => !s.destroyed && !s.closed && !s.sentTrailers;
Try / catch
try {
stream.sendTrailers(headers);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ERR_HTTP2_TRAILERS_ALREADY_SENT') {
// duplicate send; make this code path unreachable by consolidating call sites
} else throw err;
} Prevention
- Keep exactly one sendTrailers call site per stream
- Register 'wantTrailers' synchronously so the auto empty-trailers send never happens
- Check stream.sentTrailers before sending defensively
When it happens
Trigger: Calling stream.sendTrailers(...) twice (e.g. in both the data-completion path and an error path); registering waitForTrailers: true but attaching the 'wantTrailers' listener after stream.end() already fired, so the polyfill auto-sent {} and the later manual send throws.
Common situations: Error-handling paths that send error trailers after normal trailers were already sent; refactoring that moved sendTrailers into two places; listeners attached too late because they were registered after an awaited operation.
Related errors
- ERR_HTTP2_TRAILERS_NOT_READY
- ERR_HTTP2_INVALID_STREAM
- ERR_HTTP2_NESTED_PUSH
- ERR_INVALID_HTTP_TOKEN
- ERR_INVALID_CHAR
AI-assisted analysis of denoland/deno@9ad36f7a2c (2026-08-20).
Data as JSON: /api/errors/516197e49fb620cb.
Report an issue: GitHub.