microsoft/playwright · error · Error

The object has been collected to prevent unbounded heap grow

Error message

The object has been collected to prevent unbounded heap growth.

What it means

Thrown in sendMessageToServer when object._wasCollected is true — i.e. the ChannelOwner was disposed with reason 'gc' on the client side (see channelOwner._dispose: _wasCollected = reason === 'gc'). Playwright periodically garbage-collects server objects the client no longer strongly references, to cap heap growth. Calling any method on such a stale handle fails here. Note __waitInfo__ is special-cased to be silently dropped when the object is collected.

Source

Thrown at packages/playwright-core/src/client/connection.ts:186

    return this._objects.get(guid)!;
  }

  setIsTracing(isTracing: boolean) {
    if (isTracing)
      this._tracingCount++;
    else
      this._tracingCount--;
  }

  async sendMessageToServer(object: ChannelOwner, method: string, params: any, options: { apiName?: string, title?: string, internal?: boolean, frames?: channels.StackFrame[], stepId?: string, signal?: AbortSignal, timeout: number }): Promise<any> {
    // Fire-and-forget: server intentionally never replies to __waitInfo__,
    // so silently drop it after the connection is closed or the object was collected.
    if (method === '__waitInfo__' && (this._closedError || object._wasCollected))
      return;
    if (this._closedError)
      throw this._closedError;
    if (object._wasCollected)
      throw new Error('The object has been collected to prevent unbounded heap growth.');

    const signal = options.signal;
    if (signal?.aborted)
      throw new AbortError(undefined, { cause: signal.reason });

    const guid = object._guid;
    const type = object._type;
    const id = ++this._lastId;
    const message = { id, guid, method, params };
    if (debugLogger.isEnabled('channel')) {
      // Do not include metadata in debug logs to avoid noise.
      debugLogger.log('channel', 'SEND> ' + JSON.stringify(message));
    }
    const location = options.frames?.[0] ? { file: options.frames[0].file, line: options.frames[0].line, column: options.frames[0].column } : undefined;
    const metadata: channels.Metadata = { title: options.title, location, internal: options.internal, stepId: options.stepId, timeout: options.timeout };
    if (this._tracingCount && options.frames && type !== 'LocalUtils')
      this._localUtils?.addStackToTracingNoReply({ callData: { stack: options.frames ?? [], id } }).catch(() => {});
    // We need to exit zones before calling into the server, otherwise

View on GitHub (pinned to c8fc3bf8d3)

Solutions

  1. Re-acquire the handle/locator right before use (prefer Locators, which re-query each call) instead of caching handles.
  2. Scope handles tightly: obtain, use, and discard within one logical operation.
  3. Keep the page/context alive for as long as you hold references derived from them.
  4. If you genuinely need long-lived references, prevent GC by keeping them reachable on the client (but expect higher memory use).

Example fix

// before
const handle = await page.$('#item');
await page.click('#reload');
await handle.click(); // _wasCollected === true

// after — use a Locator, re-queried per call
const item = page.locator('#item');
await page.click('#reload');
await item.click();
Defensive patterns

Strategy: validation

Validate before calling

// Guard any cached handle before use. _wasCollected is private; emulate with a reachability check
// via the public API: re-resolve through a Locator, or call a cheap method and catch.
async function isHandleAlive(handle) {
  try { await handle.waitForElementState('stable', { timeout: 250 }).catch(() => {}); return true; }
  catch { return false; }
}
if (!(await isHandleAlive(handle))) throw new Error('handle collected; re-query');

Type guard

import type { ElementHandle, Locator } from 'playwright-core';

// Prefer Locators — they never go stale because they re-query per call.
function asLocator(page: any, selector: string): Locator {
  return page.locator(selector);
}

// If you must accept a handle, treat it as possibly-collected at the type level:
type MaybeStale<T> = T & { __maybeStale?: true };

Try / catch

try {
  await handle.click();
} catch (e) {
  if (/collected to prevent unbounded heap growth/.test(String(e?.message))) {
    // Re-acquire via selector and retry once.
    await page.locator(selector).click();
  } else throw e;
}

Prevention

When it happens

Trigger: Holding a long-lived reference (ElementHandle, JSHandle, Frame, Request, Response, etc.) past its actual GC window, then invoking a method on it (e.g. handle.click(), response.body()). Common in scrapers that cache handles across navigation/reloads, in tests that retain elements from a previous page state, or after the page navigated/closed and the server reclaimed the object.

Common situations: Storing element handles in arrays across awaits/loops and reusing them after a navigation; re-querying through a stale Locator's internal handle; long-running daemon that holds references too long; closing the page then touching a saved handle.

Related errors


AI-assisted analysis of microsoft/playwright@c8fc3bf8d3 (2026-08-12). Data as JSON: /api/errors/6284a64d149db805. Report an issue: GitHub.