Skip to content

createEventStream: onClosed/autoclose never fire on client disconnect in non-Node runtimes #1478

Description

@pi0x

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions