Mesrai
Back to blog
// essayTechnical Deep Dive

Event Listener Leak in a Long-Lived SSE Connection

Real PR catch: SSE subscribe without unsubscribe on client disconnect — listener stack. req.on('close') fix.

Mesrai TeamAugust 26, 20268 min read

An SSE endpoint pushing updates to clients. Subscribes to an event bus. Does not unsubscribe when the client disconnects. Listeners accumulate; memory grows.

The vulnerable diff

tsapi/sse-updates.ts
// api/sse-updates.ts
sseRouter.get("/updates", (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.flushHeaders();

  // BUG: subscribe stays attached after client disconnects
  bus.on("update", (msg) => {
    res.write(`data: ${JSON.stringify(msg)}\n\n`);
  });
});

What is wrong

Server-Sent Events are long-lived HTTP connections that push updates. The handler typically subscribes to an internal event bus and writes each event to the response. Without cleanup, when the client disconnects, the subscription remains — the bus still calls the handler, the handler tries to write to a dead response, errors accumulate, the closure pins the response in memory. Over time, listeners stack up.

The attack

Reproducer: 100 SSE connections opened, all clients close. Server still has 100 listeners.

textexploit
# After 100 cycles: bus.listenerCount("update") = 100.
# Each listener holds the response object + closure.
# Memory: ~50 MB just from leaked SSE handlers.

OOM eventually.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Performance] [Listener-Leak] [high]

SSE subscription not removed on client disconnect.

Listen for the request close event and unsubscribe:

  sseRouter.get("/updates", (req, res) => {
    res.setHeader("Content-Type", "text/event-stream");
    res.flushHeaders();

    const onUpdate = (msg: Update) => {
      res.write(`data: ${JSON.stringify(msg)}\n\n`);
    };
    bus.on("update", onUpdate);

    req.on("close", () => {
      bus.off("update", onUpdate);
    });
  });

Also set up a heartbeat so dead connections detect themselves:

  const ping = setInterval(() => res.write(": ping\n\n"), 15_000);
  req.on("close", () => clearInterval(ping));

The fix

tsapi/sse-updates.ts (fixed)
// api/sse-updates.ts — fixed
sseRouter.get("/updates", (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.flushHeaders();

  const onUpdate = (msg: Update) => {
    res.write(`data: ${JSON.stringify(msg)}\n\n`);
  };
  bus.on("update", onUpdate);

  const ping = setInterval(() => res.write(": ping\n\n"), 15_000);

  req.on("close", () => {
    bus.off("update", onUpdate);
    clearInterval(ping);
  });
});

Subscribe in handler, unsubscribe on close. Heartbeat keeps the connection alive through proxies. All cleanup attached to req.close.

Why human review missed it

SSE / WebSocket listener leaks are common in long-lived-connection code. Mesrai catches subscribe-shape calls inside handlers without paired close-event cleanup.

Related rules + further reading

Mesrai rule pack: performance/long-lived-connection-cleanup — flags SSE/WS subscriptions without close cleanup.

MDN: Server-Sent Events.

Node EventEmitter: listenerCount.

Takeaway

Subscribe + unsubscribe on close. Heartbeat for proxy keepalive. Mesrai catches every SSE without cleanup.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.