Skip to content

Completed requests leak listeners on caller-provided AbortSignals #203

Description

@abhinavkr26104

Description

Every request made with a caller-provided AbortSignal permanently adds an abort listener to that signal.

At src/core.ts:549-550, fetchWithTimeout() calls:

if (signal) signal.addEventListener('abort', () => controller.abort());

The listener is never removed after the request settles. Reusing one signal across a batch therefore retains one internal AbortController closure per completed request.

Reproduction

import { getEventListeners } from 'node:events';
import Browserbase from '@browserbasehq/sdk';

const controller = new AbortController();
const client = new Browserbase({
  apiKey: 'test',
  maxRetries: 0,
  fetch: async () =>
    new Response('{}', {
      status: 200,
      headers: { 'content-type': 'application/json' },
    }),
});

for (let i = 0; i < 12; i++) {
  await client.get('/ok', { signal: controller.signal });
}

console.log(getEventListeners(controller.signal, 'abort').length); // 12

Expected behavior

A completed request removes its abort forwarding listener, leaving zero listeners after the loop.

Actual behavior

All 12 listeners remain attached. Retries add additional listeners because each attempt creates another controller.

Why this matters

Long-lived applications commonly share a signal across a batch of requests. Listener accumulation retains completed request state and can produce unbounded memory growth for large batches. The forwarding callback should be registered once/removed in cleanup (or use an equivalent combined-signal mechanism), with coverage for success, error, timeout, and retry paths.

Tested against @browserbasehq/sdk 2.18.0 / current main (b781bd7).

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