#7030·fastify

reply.trailer() breaks 204/304 (and 205, and auto-exposed HEAD) because transfer-encoding survives the bodyless branch

Author: xia-chaoCreated Sep 15, 2026Updated Sep 15, 2026

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure the bug has not been reported

Fastify version

6.0.0-alpha.3

Plugin version

No response

Node.js version

26.8.1

Operating system

macOS

Operating system version

(latest)

Description

Using reply.trailer() together with a status code that cannot carry a body produces broken responses. There are two distinct symptoms, and I'm not sure which behaviour is intended, so I'd rather ask before writing a patch.

reply.trailer() makes Fastify add Transfer-Encoding: chunked (lib/reply.js:610, commented "it must be chunked for trailer to work"). The bodyless branch further down (lib/reply.js:660) strips content-type and content-length, but leaves transfer-encoding alone. As a result res.writeHead() is called with a header combination Node rejects.

1. 204 / 304 become 500, with a status line that contradicts itself

GET /204  ->  HTTP/1.1 500 No Content
GET /304  ->  HTTP/1.1 500 Not Modified

The reason phrase still says "No Content" / "Not Modified" while the code says 500. Node throws ERR_HTTP_TRAILER_INVALID: Trailers are invalid with this transfer encoding, and the error handler turns it into a 500.

2. 205 still generates content

GET /205  ->  205 Reset Content
              transfer-encoding: chunked
              body: "4\r\nBODY\r\n0\r\nx-t: v\r\n\r\n"

Even with the bodyless branch in place, the chunked body goes out. (This is on main; my open #7027 adds 205 to the bodyless list, which removes the body there but leaves the trailer question untouched.)

3. The auto-exposed HEAD route sends Content-Length and Transfer-Encoding together

HEAD /head-auto      ->  content-length: 4      transfer-encoding: chunked
HEAD /head-explicit  ->  content-length: (absent)  transfer-encoding: chunked

RFC 9110 §6.1: "A sender MUST NOT send a Content-Length header field in any message that contains a Transfer-Encoding header field." The explicit HEAD route gets this right, the route auto-exposed from GET does not — so the two paths disagree with each other as well.

Steps to Reproduce

A single self-contained script, no dependencies beyond Fastify. It talks to a real server over a raw socket, so the numbers below are the bytes actually sent.

javascript
import Fastify from 'fastify'
import net from 'node:net'

function raw (port, method, path) {
  return new Promise((resolve) => {
    const s = net.connect(port, '127.0.0.1', () => {
      s.write(`${method} ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\n\r\n`)
    })
    const chunks = []
    s.on('data', (c) => chunks.push(c))
    s.on('end', () => resolve(Buffer.concat(chunks).toString('latin1')))
  })
}

const app = Fastify({ logger: false })
const withTrailer = (reply, code) =>
  reply.code(code).trailer('x-t', function (r, p, done) { done(null, 'v') }).send('BODY')

for (const code of [200, 204, 205, 304]) {
  app.get(`/${code}`, function (req, reply) { withTrailer(reply, code) })
}
app.head('/head-explicit', function (req, reply) { withTrailer(reply, 200) })
app.get('/head-auto', function (req, reply) { withTrailer(reply, 200) })

await app.listen({ port: 0, host: '127.0.0.1' })
const { port } = app.server.address()

const cases = [
  ['GET', '/200', 'control'],
  ['GET', '/204', 'RFC 9110 15.3.5'],
  ['GET', '/205', 'RFC 9110 15.3.6'],
  ['GET', '/304', 'RFC 9110 15.4.5'],
  ['HEAD', '/head-explicit', 'explicit HEAD route'],
  ['HEAD', '/head-auto', 'auto HEAD from GET']
]

for (const [method, path] of cases) {
  const text = await raw(port, method, path)
  const [head, ...rest] = text.split('\r\n\r\n')
  const lines = head.split('\r\n')
  const cl = lines.find((l) => /^content-length:/i.test(l))
  const te = lines.find((l) => /^transfer-encoding:/i.test(l))
  console.log(
    `${(method + ' ' + path).padEnd(19)}| ${lines[0].replace('HTTP/1.1 ', '').padEnd(19)}` +
    `| CL ${(cl ? cl.split(':')[1].trim() : '-').padEnd(7)}` +
    `| TE ${(te ? te.split(':')[1].trim() : '-').padEnd(8)}` +
    `| ${JSON.stringify(rest.join('\r\n\r\n'))}`
  )
}

await app.close()

Actual Behaviour

raw socket bytes for each case

request status line content-length transfer-encoding body
GET /200 200 OK absent chunked "4\r\nBODY\r\n0\r\nx-t: v\r\n\r\n"
GET /204 500 No Content 145 chunked ""
GET /205 205 Reset Content absent chunked "4\r\nBODY\r\n0\r\nx-t: v\r\n\r\n"
GET /304 500 Not Modified 145 chunked ""
HEAD /head-explicit 200 OK absent chunked ""
HEAD /head-auto 200 OK 4 chunked ""

Debugger evidence

A conditional breakpoint on the writeHead call shows the header set handed to Node at the moment it refuses:

breakpoint on the writeHead call

expression value
statusCode 204
reply[kReplyHeaders] {"transfer-encoding":"chunked","trailer":"x-t"}
reply[kReplyHeaders]['content-length'] undefined (already removed)
err.code ERR_HTTP_TRAILER_INVALID

The two blocks involved are 45 lines apart and never look at each other — the first adds Transfer-Encoding unconditionally, the second only cleans up content-type / content-length:

the two blocks side by side

Expected Behaviour

I'm not sure what the intended behaviour is for 204/304 here, so I've asked separately in the comments below rather than guessing at a fix.

Additional notes

  • Not covered by the open trailer PRs: #6953 is about when stream trailers are sent, #6976 is about a sync-returning trailer handler hanging. Neither touches the bodyless-status interaction. #6699 refactors onSendEnd without changing behaviour, so the gap would survive it.
  • I found no existing issue mentioning ERR_HTTP_TRAILER_INVALID.