Client-disconnect cleanup for createEventStream only works on Node — on web-standard runtimes (Deno, Bun, Workerd, service workers, plain app.fetch() consumers) neither autoclose nor onClosed ever fires when the client disconnects. Found during a pre-stable validation pass, reproduced against current main (75ac47d).
autoclose is wired exclusively to the Node runtime
src/utils/internal/event-stream.ts:31-33:
if (opts.autoclose !== false) {
this._event.runtime?.node?.res?.once("close", () => this.close());
}
On every non-Node runtime runtime?.node?.res is undefined, so a client disconnect never auto-closes the stream.
onClosed callback is swallowed on client disconnect
src/utils/internal/event-stream.ts:165-167:
onClosed(cb: () => any): void {
this._writer.closed.then(cb).catch(_noop);
}
When the client disconnects, the readable side of the TransformStream is cancelled, which puts the writable side into an errored state — so writer.closed rejects, the callback is routed into .catch(_noop), and never runs. It only fires on a graceful close().
Net effect
The exact cleanup pattern the createEventStream docs recommend silently does nothing on client disconnect on non-Node runtimes:
const eventStream = createEventStream(event);
const interval = setInterval(() => eventStream.push("tick"), 1000);
eventStream.onClosed(() => {
clearInterval(interval); // never runs on web/Deno/Bun/Workerd disconnect
});
return eventStream.send();
Verified on the web path: after reader.cancel() on the response body, onClosed never fired and the interval kept ticking (15+ pushes after disconnect). Timers, DB handles, and generator loops leak per connection.
Event framing itself (multi-line data:, id/event/retry, comments, sanitization) checked out fine — this is purely the disconnect/cleanup path.
A portable fix likely means detecting cancellation of the readable side (e.g. observing writer.closed rejection as a close signal, and/or a cancel hook on the readable) rather than relying on node.res close.
Client-disconnect cleanup for
createEventStreamonly works on Node — on web-standard runtimes (Deno, Bun, Workerd, service workers, plainapp.fetch()consumers) neitherautoclosenoronClosedever fires when the client disconnects. Found during a pre-stable validation pass, reproduced against currentmain(75ac47d).autocloseis wired exclusively to the Node runtimesrc/utils/internal/event-stream.ts:31-33:On every non-Node runtime
runtime?.node?.resis undefined, so a client disconnect never auto-closes the stream.onClosedcallback is swallowed on client disconnectsrc/utils/internal/event-stream.ts:165-167:When the client disconnects, the readable side of the
TransformStreamis cancelled, which puts the writable side into an errored state — sowriter.closedrejects, the callback is routed into.catch(_noop), and never runs. It only fires on a gracefulclose().Net effect
The exact cleanup pattern the
createEventStreamdocs recommend silently does nothing on client disconnect on non-Node runtimes:Verified on the web path: after
reader.cancel()on the response body,onClosednever fired and the interval kept ticking (15+ pushes after disconnect). Timers, DB handles, and generator loops leak per connection.Event framing itself (multi-line
data:,id/event/retry, comments, sanitization) checked out fine — this is purely the disconnect/cleanup path.A portable fix likely means detecting cancellation of the readable side (e.g. observing
writer.closedrejection as a close signal, and/or acancelhook on the readable) rather than relying onnode.resclose.