microsoft/playwright · error · Error

The object has been collected to prevent unbounded heap…

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.

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@500c9c822c (2026-08-12). Data as JSON: /api/errors/6284a64d149db805. Report an issue: GitHub.

Appendix: source

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

    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[], callId?: 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 = options.callId ?? this._nextCallId();
    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, 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 500c9c822c)