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

  1. Register the 'wantTrailers' listener synchronously, before ending the stream, whenever you respond with waitForTrailers: true
  2. Send trailers from exactly one place — inside the 'wantTrailers' listener
  3. 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

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


AI-assisted analysis of denoland/deno@9ad36f7a2c (2026-08-20). Data as JSON: /api/errors/516197e49fb620cb. Report an issue: GitHub.