Puppeteer errors involving page.exposeFunction() and selector lookups have different causes. An exposed-function failure is usually a bridge, execution-context, navigation, or frame-lifecycle problem; a selector problem is usually a return-value, DOM-timing, frame, shadow-root, or CSS-syntax problem. Start by recording the installed Puppeteer version, browser, frame context, exact error or result, and the moment the call runs. The current Puppeteer API reference displays version 25.12.0, while the cross-site iframe race discussed below was reported against 25.5.0, so do not generalize that issue to every release.
First, identify which contract failed
Use the observed result—not the similar-looking symptom—to choose a fix.
| Call | Contract | Typical failure signal | First check |
|---|---|---|---|
page.exposeFunction(name, callback) |
Installs window[name] in the page; the callback executes in Node.js. The installation call returns a promise, and page calls return a promise for the callback result. |
A rejected promise such as Protocol error (Runtime.addBinding): Target closed, or a missing window function. |
Await installation, then check the page and each frame while navigation or iframe churn is occurring. |
page.$(selector) |
Returns the first matching element in the main frame, or null when nothing matches. |
null, a later “cannot read properties of null” error, or a selector syntax exception. |
Log the exact selector, query at the right time and in the right frame, and handle null. |
document.querySelector(selector) |
Uses the browser’s native CSS selector grammar in the current document. | A SyntaxError for invalid CSS, or null for no match. |
Validate native CSS and confirm the node is in this document, not an iframe or shadow root. |
The official references are Page.exposeFunction() and Page.$(). Puppeteer accepts ordinary CSS and also supports Puppeteer-specific selector syntax, including text, accessibility role/name, XPath and combinations across shadow roots. Native document.querySelector does not automatically understand those Puppeteer-specific prefixes.
Fixing page.exposeFunction()
Install the bridge before page code calls it
exposeFunction is asynchronous. Await it before evaluating code that calls the new window function. The callback remains Node-side, so keep filesystem, network and other Node-only work inside that callback and pass serializable arguments across the boundary.
Free tools Windows power users keep installed
One-click scans. No signup required.
#1 Best Overall
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.exposeFunction('lookupUser', async (id) => {
// This callback runs in Node.js.
return { id, displayName: `user-${id}` };
});
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
const user = await page.evaluate(async () => {
// This call runs in the page and returns a Promise.
return await window.lookupUser('42');
});
console.log(user);
await browser.close();
For a missing function, verify all of these items:
- The installation line is reached and awaited; do not fire it without awaiting.
- The name is identical, including capitalization, and the page invokes
window.lookupUser. - The callback does not rely on page globals, and its arguments and return value can cross the protocol boundary.
- You are testing the same
Pageobject and frame that received the binding.
Navigation is not normally the culprit
The current API documentation explicitly states: “Functions installed via page.exposeFunction survive navigations.” If your setup loses a binding, capture the exact Puppeteer version, browser, navigation sequence and whether exposure happened before or after navigation. An old report in issue history should not override the current contract.
Investigate a Target closed rejection during iframe churn
GitHub issue #15299, opened August 6, 2026, describes Puppeteer 25.5.0 on Node.js 22.20.0 and Windows. The reproduction creates eight cross-site iframes, removes them while page.exposeFunction is running, and receives Protocol error (Runtime.addBinding): Target closed. The author reports that the main-frame binding still existed and a later evaluation returned 42. This points to a reported race while Puppeteer installs bindings through per-frame protocol sessions—not proof that every rejection leaves a usable page.
Before retrying, stop frame churn if possible and inspect the actual state:
try {
await page.exposeFunction('healthCheck', () => 42);
} catch (error) {
console.error('exposeFunction failed:', error);
console.error('version:', process.env.npm_package_dependencies_puppeteer);
console.error('frames:', page.frames().map(frame => ({
url: frame.url(),
detached: frame.isDetached()
})));
const mainFrameBinding = await page.evaluate(() =>
typeof window.healthCheck === 'function'
).catch(() => false);
console.error('main-frame binding present:', mainFrameBinding);
}
Do not blindly retry or continue startup after a rejection. Determine which frames are attached, whether the main-frame binding exists, and whether the application is still adding or removing cross-site frames. Then create a minimal reproduction and test any sequencing or version change against your own frame lifecycle.
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Scan for outdated or missing drivers - takes under a minuteDriver Scan →Repair Windows errors before they cause bigger problemsFix Now →Check the installed version before applying issue-specific advice
In the issue report, the author’s bisect found that 22.12.1 resolved their reproduction while 22.13.0 threw, even though the issue lists 25.5.0. Those are observations from that environment, not a compatibility guarantee. Print the resolved package version (for example, with your package manager’s dependency command), record the browser revision, and test upgrades or downgrades in a locked environment. Include the version in bug reports.
Rank #2
Fixing page.$ and querySelector lookups
Handle the documented null result
page.$ does not throw simply because no element matches. It resolves to null. The exception often appears later when code dereferences the result.
const submit = await page.$('button[type="submit"]');
if (!submit) {
throw new Error('Required submit button is absent at query time');
}
await submit.click();
For an element that must eventually appear, wait for the application’s readiness condition rather than assuming that navigation completion rendered it:
await page.waitForSelector('[data-testid="results"]', {
visible: true,
timeout: 15_000
});
const results = await page.$('[data-testid="results"]');
if (!results) throw new Error('Results disappeared after waiting');
Use the installed Puppeteer version’s current wait and assertion APIs, and choose a condition that represents your application’s state (a specific attribute, text, network response or rendered component), not an arbitrary delay.
Distinguish Puppeteer selectors from native CSS
Puppeteer’s selector engine can combine CSS with text, accessibility role/name, XPath and shadow-root traversal. A selector such as a Puppeteer-specific text or role form may work in page.$ but throw or return null when passed to document.querySelector. Conversely, native CSS must be valid CSS in both contexts.
// Puppeteer selector API
const button = await page.$('aria/Submit order');
// Native DOM API: use ordinary CSS only
const nativeButton = await page.evaluate(() =>
document.querySelector('button[type="submit"]') !== null
);
When native querySelector throws SyntaxError, inspect quoting, brackets, escapes and pseudo-selector support. When it returns null, inspect the DOM at that instant rather than changing the selector at random.
Query the correct frame
page.$ is a shortcut for the main frame. An element inside an iframe is not part of that document:
const frameHandle = await page.waitForSelector('iframe.payment');
const frame = await frameHandle.contentFrame();
if (!frame) throw new Error('Payment iframe is not attached');
await frame.waitForSelector('input[name="cardnumber"]');
const card = await frame.$('input[name="cardnumber"]');
if (!card) throw new Error('Card field is absent in payment frame');
For cross-origin frames, use Puppeteer’s frame methods rather than trying to reach through the browser’s same-origin boundary with a page evaluation.
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Fix the driver behind crashes, sound loss and screen glitches3Clear out junk files and repair common Windows errorsAccount for shadow roots and asynchronous rendering
If the target is rendered after a client-side request, query only after the relevant readiness signal. If it is inside a shadow root, use Puppeteer’s supported shadow-root selector features or evaluate within the component’s shadow root with native DOM methods. A successful navigation means the document loaded; it does not prove that an application finished rendering the target.
Log a reproducible diagnostic snapshot
const selector = '[data-testid="checkout"]';
console.log({
selector,
pageUrl: page.url(),
frameUrls: page.frames().map(frame => frame.url())
});
const existsInMainDocument = await page.evaluate((s) => {
try {
return { found: !!document.querySelector(s), error: null };
} catch (error) {
return { found: false, error: String(error) };
}
}, selector);
console.log(existsInMainDocument);
This separates an invalid selector from a valid selector with no match and records the URL and frame situation needed to reproduce the timing.
A practical debugging sequence
- Record the resolved Puppeteer version, browser version, operating system, URL and exact call site.
- Classify the symptom: rejected exposure, missing window function,
null, native selectorSyntaxError, or later null dereference. - For exposure, await installation, test the binding from the intended page, and inspect frame attachment during navigation or iframe changes.
- For selectors, log the literal selector and query it in the correct frame and execution context.
- Replace arbitrary sleeps with a specific wait or application readiness signal.
- Reduce the case to one page, one frame and one call before changing versions or adding retries.
- After a fix, rerun with the original navigation and frame lifecycle; a workaround that only works on a static page is incomplete.
Performance, reliability and cost considerations
Repeated selector polling, unnecessary retries and capturing diagnostics after every failure can slow a suite and obscure the race. Prefer one readiness wait, fail with the selector and frame URL, and preserve a small HTML or screenshot artifact only when troubleshooting. Exposure should normally happen once per page lifecycle; duplicate installation attempts can create confusing startup ordering.
Rank #4
For unstable third-party pages, isolate external frame creation from your own test’s setup, and treat a detached frame as a state transition rather than a transient error to suppress. Pin the version while investigating, then test a planned upgrade with the same reproduction.
Or skip the browser setup
If your goal is a clean image or PDF rather than interactive browser automation, ScreenshotNeo provides a single website-screenshot request. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups and chat widgets; each step can be disabled. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. It also offers an MCP server for Claude, Cursor and other MCP clients, with take_screenshot, get_page_info and capture_pdf tools.
With the API documented at ScreenshotNeo docs, the basic call is:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
The equivalent Python and Node.js requests are:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo supports full-page captures with lazy images loaded, CSS-selector element captures, dark mode, 12 device presets or any viewport, retina scale, PDF paper settings and page ranges, HTML/CSS rendering, custom CSS and JavaScript, clicks, selector or network-idle waits, ad/tracker/request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API and an OpenAPI specification. Parameter names used by other screenshot APIs also work.
The Free plan includes 1,000 shots each month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan, and annual billing provides two months free. Sign up for ScreenshotNeo to use the free allowance.
Common errors and targeted fixes
| Symptom | Likely cause | Action |
|---|---|---|
Target closed from exposeFunction |
A frame target detached during binding installation, especially in the reported out-of-process iframe reproduction. | Check version and frame churn; inspect whether the main-frame binding exists; reproduce before choosing a retry or version change. |
window.myFunction is not a function |
Installation was not awaited, the name differs, or code is running in another page/frame. | Await installation and invoke the exact name in the intended context. |
page.$() returns null |
No match at that instant, wrong frame, asynchronous rendering, shadow DOM, or selector mismatch. | Log the literal selector, wait for readiness, use the frame context and handle null. |
Native querySelector throws |
Invalid CSS syntax or a Puppeteer-only selector was passed to the browser DOM API. | Validate CSS and use Puppeteer’s selector API for Puppeteer-specific syntax. |
| Selector works intermittently | Application timing or frame attachment changes. | Wait on a meaningful readiness condition and record frame URLs and page state. |
FAQ
Does exposeFunction survive a page navigation?
Yes. The current Puppeteer API documentation says functions installed with page.exposeFunction survive navigations. If yours does not, record the version and navigation sequence because the behavior may be setup-specific.
Best Value
Is a null result from page.$ an exception?
No. It is the documented “no matching element” result. The exception usually occurs when later code treats that null value as an element.
Can I use an aria/ or text selector inside document.querySelector?
Not as a general rule. Those are Puppeteer selector forms; native querySelector expects browser CSS syntax.
Should every failed exposeFunction call be retried?
No. A reported frame-target race can leave partial state, so first inspect bindings and frame health. Retrying without understanding lifecycle can mask the underlying defect.
The Tool Desk
Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Frequently Asked Questions
Which details should I include in a Puppeteer bug report?
Include the exact Puppeteer and browser versions, Node.js version, operating system, URL, frame structure, navigation order, literal selector or exposed-function name, complete error text, and whether the page was creating or removing frames.
What is the fastest way to tell whether a selector is invalid or merely absent?
Run the literal selector in a small try/catch: a native CSS syntax exception means invalid syntax; a valid call returning null means no matching node in that document at that moment.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

