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
- 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
- Browser context was closed before the daemon could start
- Cannot disconnect from this worker
- Cannot find object to
- Cannot find parent object
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, otherwiseView on GitHub (pinned to 500c9c822c)