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, otherwiseView on GitHub (pinned to c8fc3bf8d3)
Solutions
- Re-acquire the handle/locator right before use (prefer Locators, which re-query each call) instead of caching handles.
- Scope handles tightly: obtain, use, and discard within one logical operation.
- Keep the page/context alive for as long as you hold references derived from them.
- 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
- Default to Locators over ElementHandle for anything kept across awaits.
- Never cache handles across navigation, reload, or route changes.
- Tear down handles you no longer need so GC is predictable.
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
- Cannot set input files to detached element
- Cannot find object to "${method}": ${guid}
- Unknown new child: ${params.guid}
- Cannot find parent object ${parentGuid} to create ${guid}
- Cannot set buffer larger than 50Mb, please write it to a fil
AI-assisted analysis of microsoft/playwright@c8fc3bf8d3 (2026-08-12).
Data as JSON: /api/errors/6284a64d149db805.
Report an issue: GitHub.