#5313·hono

Expose a non-allocating read for pending response headers on Context

Author: VicenzoMFCreated Aug 28, 2026Updated Aug 28, 2026
Labelsenhancement

What is the feature you are proposing?

There is no public way to ask whether pending response headers exist, or to read them, without materialising a Response.

The gap

c.header() writes into the private #preparedHeaders and does not set finalized (src/context.ts:519-523; finalized flips only in the res setter, :433). So the one public flag that looks like it should answer "did anything touch the response?" doesn't.

The only public read is c.res, whose getter allocates (src/context.ts:403-407):

typescript
get res(): Response {
  return (this.#res ||= createResponseInstance(null, {
    headers: (this.#preparedHeaders ??= new Headers()),
  }))
}

And reading it is not a neutral inspection — it materialises #res, after which header() writes into #res.headers and text() loses its fast path. Both points, on main at e2740d5:

typescript
import { Hono } from './index'

it('c.header() does not set finalized', async () => {
  let finalizedAfterHeader: boolean | undefined
  const app = new Hono()
  app.use(async (c, next) => {
    c.header('x-from-middleware', 'yes')
    finalizedAfterHeader = c.finalized
    await next()
  })
  app.get('/', (c) => c.text('hi'))
  const res = await app.request('/')
  expect(finalizedAfterHeader).toBe(false)              // passes
  expect(res.headers.get('x-from-middleware')).toBe('yes') // the header did land
})

it('merely reading c.res changes the response', async () => {
  const build = (observe: boolean) => {
    const app = new Hono()
    app.use(async (c, next) => {
      if (observe) {
        void c.res
      }
      await next()
    })
    app.get('/', (c) => c.text('hi'))
    return app.request('/')
  }
  const untouched = await build(false)
  const observed = await build(true)
  // same route, same handler, different response:
  //   untouched -> 'text/plain;charset=UTF-8'   (text() fast path, new Response(text))
  //   observed  -> 'text/plain; charset=UTF-8'  (#newResponse)
  expect(observed.headers.get('content-type')).not.toBe(untouched.headers.get('content-type'))
})

That content-type difference is not what I'm reporting — it is just the cheapest proof that observing costs more than an allocation.

Hono already needs this predicate

text() at :704:

typescript
return !this.#preparedHeaders && !this.#status && !arg && !headers && !this.finalized

The check exists — it's just private.

Why it matters outside the core too

Code that wraps Hono and builds its own Response at the end of the chain has to know whether a middleware left headers behind. With no public read there are two options: pay for c.res on every request, or track it yourself. I maintain a framework that does the latter — a Symbol() marker set after every wrapped middleware runs, plus a setter, a getter and a branch: four pieces of machinery standing in for one boolean, and it still can't avoid the allocation on the requests that do have headers.

Possible shapes

  • get preparedHeaders(): Headers | undefined — answers both existence and contents, allocates nothing. Downside: hands out the live Headers, so a caller could mutate it. Returning a copy would allocate, which defeats half the point.
  • get hasPreparedHeaders(): boolean — minimal and leak-free, but a caller that then wants the values is back to c.res.

Both are ~40 bytes on the minified ESM bundle, measured against main at e2740d5 with perf-measures/bundle-check: 18134 B baseline, 18171 B with preparedHeaders, 18183 B with hasPreparedHeaders. Neither adds anything to an existing code path — and the boolean is the larger of the two, so bundle size isn't a reason to prefer it.

I'd lean to the first, and I have no stake in the naming.