What happens
A WebSocket route's beforeHandle hook runs twice for a single upgrade.
This matters beyond redundant work: beforeHandle is the documented place to put upgrade authorization and rate limiting, and both are side-effecting. In our app the hook consumes an upgrade-throttle token and validates the session, so every connection burned two tokens (the flood limit silently enforced half its configured value) and issued two session-auth database round trips.
Reproduction
npm install elysia@1.4.29 @elysiajs/node@1.4.5 ws@8
import { Elysia } from "elysia";
import { node } from "@elysiajs/node";
import WebSocket from "ws";
let calls = 0;
new Elysia({ adapter: node() })
.ws("/probe", {
beforeHandle() {
calls++;
},
open() {},
})
.listen(31890);
await new Promise((r) => setTimeout(r, 500));
const sock = new WebSocket("ws://127.0.0.1:31890/probe");
await new Promise((res) => {
sock.on("open", res);
sock.on("error", res);
});
console.log("beforeHandle invocations for ONE upgrade:", calls);
process.exit(0);
Expected: 1
Actual: 2
Cause
dist/ws.mjs registers the route by passing the whole options object (which contains beforeHandle) to app.route(...), so Elysia runs beforeHandle as part of its normal route lifecycle. The adapter's own route handler then invokes the same hook a second time:
// dist/ws.mjs:46
app.route(
"WS",
path,
// @ts-ignore
async (context) => {
...
// dist/ws.mjs:75
if (typeof options.beforeHandle === "function") {
const result = options.beforeHandle(context);
if (result instanceof Promise) await result;
}
Since Elysia has already executed the hook via the lifecycle attached to that same options object, the explicit call at line 75 is a second invocation.
Environment
@elysiajs/node 1.4.5 (latest published)
elysia 1.4.29
- Node.js 24.18.0
- macOS (arm64)
Workaround
For anyone hitting this before a fix lands: deduplicate by the concrete Request object, which is stable across both invocations.
function oncePerRequest(operation) {
const runs = new WeakMap();
return async (context) => {
let run = runs.get(context.request);
if (!run) {
run = operation(context).then(
() => ({ failed: false, status: context.set.status }),
(error) => ({ error, failed: true, status: context.set.status }),
);
runs.set(context.request, run);
}
const outcome = await run;
context.set.status = outcome.status;
if (outcome.failed) throw outcome.error;
};
}
The status/error replay matters because the adapter may build a second context around the same Request, so a rejection in the first run still has to reject the second caller.
Possibly related
While tracing this I noticed dist/ws.mjs:117 intercepts any message whose payload merely contains the substring "ping" and routes it to ws.pong() instead of message():
async message(ws, message) {
if (message.includes("ping")) {
try {
return void ws.pong(message);
} catch (error) {
handleErrors?.(ws, error);
}
}
A JSON heartbeat like {"type":"ping"} matches that substring test. In my minimal repro the handler still received the message because ws.pong() threw and the catch fell through, so I can't demonstrate it cleanly and I'm not filing it as a separate bug. But a substring match against arbitrary user payloads looks unintended regardless: any application message containing "ping" anywhere ({"action":"shipping"}) is a candidate for interception depending on whether pong succeeds. Happy to open a separate issue if useful.
What happens
A WebSocket route's
beforeHandlehook runs twice for a single upgrade.This matters beyond redundant work:
beforeHandleis the documented place to put upgrade authorization and rate limiting, and both are side-effecting. In our app the hook consumes an upgrade-throttle token and validates the session, so every connection burned two tokens (the flood limit silently enforced half its configured value) and issued two session-auth database round trips.Reproduction
Expected:
1Actual:
2Cause
dist/ws.mjsregisters the route by passing the wholeoptionsobject (which containsbeforeHandle) toapp.route(...), so Elysia runsbeforeHandleas part of its normal route lifecycle. The adapter's own route handler then invokes the same hook a second time:Since Elysia has already executed the hook via the lifecycle attached to that same
optionsobject, the explicit call at line 75 is a second invocation.Environment
@elysiajs/node1.4.5 (latest published)elysia1.4.29Workaround
For anyone hitting this before a fix lands: deduplicate by the concrete
Requestobject, which is stable across both invocations.The status/error replay matters because the adapter may build a second context around the same
Request, so a rejection in the first run still has to reject the second caller.Possibly related
While tracing this I noticed
dist/ws.mjs:117intercepts any message whose payload merely contains the substring"ping"and routes it tows.pong()instead ofmessage():A JSON heartbeat like
{"type":"ping"}matches that substring test. In my minimal repro the handler still received the message becausews.pong()threw and thecatchfell through, so I can't demonstrate it cleanly and I'm not filing it as a separate bug. But a substring match against arbitrary user payloads looks unintended regardless: any application message containing"ping"anywhere ({"action":"shipping"}) is a candidate for interception depending on whetherpongsucceeds. Happy to open a separate issue if useful.