โ† All posts
ENGINEERING August 20, 2026 ยท 3 min read

The property read that drained the request

SR
Sentrinel Team
Product & engineering

We shipped a change to the Elysia plugin that added the client's IP address and country to every request record. It worked on every route we tested. Then a customer reported that most of their routes had stopped recording anything at all.

Not errors. Not slow. Gone. The plugin ran, the request completed, and no record was produced. Only some routes โ€” and the pattern took a while to see: every route with a typed body schema.

What we had written

The change read two headers in the plugin's afterResponse hook โ€” the forwarded-for header for the IP, and a country header if a CDN had set one:

const ip = ctx.request.headers.get("x-forwarded-for");

One line. It looks like the most harmless code in the world. Reading a header off a request is not supposed to have side effects.

What Elysia does with ctx.request

Elysia's context is lazy. Several properties on it โ€” including request โ€” are not plain values but accessors, and touching one can trigger work that has not happened yet. In particular, reading ctx.request inside a hook can trigger the eager parsing of the request body.

On a route with a body schema, that body has already been consumed by the route handler. A second read of the stream throws. The throw happens inside our hook, our hook is wrapped in a try/catch that was there to guarantee the plugin never breaks a customer's request โ€” and so the record was silently dropped. Every time. On exactly the routes that had a body.

The plugin did precisely what its own safety net was designed to do: fail quietly rather than fail the request. Which is why it looked like nothing was wrong.

The fix that was not a fix

The first attempt moved the header read earlier, into beforeHandle, on the theory that the body had not been consumed yet. It had the same effect, one hook earlier: reading ctx.request there triggered the parse, and the handler then found a drained stream. Now the customer's routes were broken instead of ours. That version did not ship.

The second attempt copied the headers into a plain object in onRequest โ€” the earliest hook, before any parsing โ€” and stored them on the context for afterResponse to read. Better. But "stored them on the context" meant a property whose read was, once again, going through the lazy machinery in a later hook. Same failure, different line.

The fix

The headers are captured in onRequest, where reading the raw Request is safe, and stored in a WeakMap keyed by the Request object itself. In afterResponse the lookup is by identity โ€” the same object โ€” and nothing on ctx is touched.

const captured = new WeakMap<Request, { ip: string | null; country: string | null }>();

.onRequest(({ request }) => {
  captured.set(request, { ip: clientIp(request), country: clientCountry(request) });
})
.onAfterResponse(({ request }) => {
  const meta = captured.get(request); // identity, not a context property
  โ€ฆ
})

The WeakMap matters for the reason WeakMaps always matter: the entry disappears with the request and cannot leak. The identity lookup matters for a subtler reason: it is the only way to get data from one hook to another without going through the context object whose reads are the problem.

Two things the tests caught

The first version of the fallback chain also tried headers.get(":authority") for the host on HTTP/2. That call throws โ€” the pseudo-header name is invalid to the Headers API โ€” and would have taken the whole capture with it. Our own test caught it before a customer did. It is gone.

And the test that now guards all of this is the blunt one: a typed route, a request with a body, and an assertion that a record was produced. It failed on every version of the fix but the last, which is the property you want from a regression test.

The general shape

The bug was not "Elysia is wrong". Lazy contexts are a reasonable design, and the parse-on-access behaviour is documented. The bug was that a read had a side effect, and every instinct about reads says they do not. When a framework hands you a context object, assume that touching it is an operation โ€” and if you need to carry something from an early hook to a late one, carry it beside the framework, not through it.