reply.trailer() breaks 204/304 (and 205, and auto-exposed HEAD) because transfer-encoding survives the bodyless branch
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 ModifiedThe 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: chunkedRFC 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.
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

| 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:

| 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:

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
onSendEndwithout changing behaviour, so the gap would survive it. - I found no existing issue mentioning
ERR_HTTP_TRAILER_INVALID.
Source: fastify/fastify