Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

If Puppeteer’s page.$$eval() returns an empty array, undefined, or data you did not expect, check three things first: how many elements the selector matches, whether those elements exist when the callback runs, and whether the callback explicitly returns the value you want. $$eval passes an array of matching elements to a function in the page context; the function’s return value is the result of the call.

What page.$$eval() returns

The method takes a selector, a page-context function, and optional arguments:

page.$$eval(selector, pageFunction, ...args)

It finds all elements matching the selector in the page being queried, passes that array as the first argument to pageFunction, and resolves to the function’s return value. If the function returns a promise, Puppeteer waits for it. The callback does not receive one element; use page.$eval() when you intend to work with a single match.

If there are no matches, the callback receives an empty array. For example, elements => elements.map(...) returns an empty array in that case. Puppeteer does not infer which element you intended or create a result for a missing match. The method’s contract and examples are in the Puppeteer Page.$$eval() API documentation.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Diagnose the result before changing the code

Check the match count

Start with the smallest possible query. It separates a selector or timing issue from a problem in your transformation:

const count = await page.$$eval('.result', elements => elements.length);
console.log({ count });

A count of zero means the selector found nothing in the queried page at that moment. Check the selector, frame context, and whether the content has appeared yet. A positive count means matching elements exist; inspect what the callback reads and returns.

Inspect the actual values

When the count is positive but the output is surprising, temporarily return simple properties rather than the full transformation:

const values = await page.$$eval('.result', elements =>
  elements.map(element => ({
    tag: element.tagName,
    text: element.textContent,
    className: element.className,
  }))
);
console.log(values);

This helps distinguish a selector that matches the wrong elements from a callback that reads the wrong property. For example, visible text may include whitespace or nested content you did not expect. Normalize it deliberately rather than assuming the DOM text is already clean.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Fix an empty array or zero matches

Verify selector spelling and scope

Confirm the page actually uses the class, attribute, or structure in your selector. A selector that is valid CSS can still describe the wrong element, or may be evaluated against a different page state than the one visible in your browser. Check whether the desired content is inside an iframe: a query against the main page does not automatically search every frame. Query the relevant Puppeteer frame when the target belongs to an iframe.

Plain CSS selectors also do not cross into Shadow DOM. Puppeteer supports additional selector syntax, including deep combinators for traversing open shadow roots. Consult the Puppeteer page interactions guide for supported selector syntax and its limitations; do not assume a normal CSS selector can see through a shadow boundary.

Wait for dynamic content

Navigation finishing is not the same as a client-rendered list being present. If a page fills its results after JavaScript runs, query only after the element or condition you need has appeared. Puppeteer recommends locators for element selection and interaction because they wait for presence and the appropriate state. For extraction, you can also use an explicit wait before $$eval:

await page.waitForSelector('.result');
const rows = await page.$$eval('.result', elements =>
  elements.map(element => element.textContent?.trim() ?? '')
);

waitForSelector() is a lower-level option, not a guarantee that every later action will automatically be retried. Choose the condition that corresponds to the data you need: waiting for a container may be insufficient if the container appears before its results are populated. The page interactions guide covers locators, waits, and selection behavior.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Wait for navigation when a click changes pages

If clicking a link triggers navigation, starting a navigation wait only after the click can lose a race with a fast navigation. Start both operations together, then query the resulting page:

const [response] = await Promise.all([
  page.waitForNavigation(),
  page.click('a.next'),
]);

const values = await page.$$eval('.result', elements =>
  elements.map(element => element.textContent?.trim() ?? '')
);

The response can be useful if you need to inspect the navigation result; the extraction is deliberately performed after the paired wait and click complete. Puppeteer documents this race in its Page class API reference.

Fix undefined or missing callback output

$$eval returns what the callback returns. An arrow function with an expression body returns that expression automatically:

const rows = await page.$$eval('.result', elements =>
  elements.map(element => element.textContent?.trim() ?? '')
);

But a callback with braces needs an explicit return. This version returns undefined, even though it builds an array internally:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const rows = await page.$$eval('.result', elements => {
  elements.map(element => element.textContent?.trim() ?? '');
});

Correct it by returning the mapping result:

const rows = await page.$$eval('.result', elements => {
  return elements.map(element => element.textContent?.trim() ?? '');
});

Also check that your callback returns a serializable value suitable for the result you need. The callback runs in the browser page context, so it is not a normal closure over your Node.js variables. Pass Node-side values through the method’s extra arguments instead:

const prefix = 'item:';
const values = await page.$$eval(
  '.result',
  (elements, prefix) =>
    elements.map(element => `${prefix}${element.textContent?.trim() ?? ''}`),
  prefix,
);

The extra arguments are passed to the page function after the elements array. For more on page-context execution and argument passing, see Puppeteer’s Page.evaluate() API documentation.

Choose the right Puppeteer API

Need Use Why
Transform every current match into a value page.$$eval() It supplies all matching elements to one callback and returns the callback’s result.
Work with one matching element page.$eval() Use a single-element query when the task is inherently about one match.
Interact with an element and account for its state A Puppeteer locator The current interactions guide recommends locators for selection and interaction because they wait for element presence and appropriate state.
Run broader page-context logic page.evaluate() Use it when the logic is not simply a query-and-transform over all matches.

These are different tools for different jobs: a locator can address waiting and interaction, while $$eval is a direct query-and-transform operation. See the Puppeteer interactions guide for its current recommendations.

Separate TypeScript errors from runtime mismatches

A TypeScript complaint about an element property does not prove the selector returned no elements. The API documentation types the callback input as Element[] by default. If you need a property available only on a particular subtype, narrow or specify the appropriate element type, such as an input element when reading its value. Then debug the runtime match count separately.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const values = await page.$$eval('input', inputs =>
  inputs.map(input => (input as HTMLInputElement).value)
);

Use a subtype only when the matched nodes really are that kind of element. A cast changes TypeScript’s understanding; it does not change the DOM nodes or make a selector match more elements. The API’s documented TypeScript examples are in the Page.$$eval() reference.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Common failure patterns and fixes

  • Empty array: The callback ran but received no matches. Verify the selector, page or frame, Shadow DOM boundary, and timing; log the match count.
  • undefined despite seeing elements: Check for a callback using braces without return, or for code that computes a value but never returns it.
  • Wrong or blank text: Inspect the matched elements’ tag, text, and attributes. Confirm the selector is targeting the intended nodes and normalize whitespace or handle missing text explicitly.
  • Node variable is unavailable inside callback: The function executes in the page context. Pass the value using ...args rather than closing over it.
  • Results are intermittent: The page may insert or update content asynchronously. Wait for the relevant condition, not merely for navigation to finish.
  • Works on a top-level page but not an embedded view: Query the frame that contains the content.
  • TypeScript rejects a property: Narrow or use the correct element subtype; keep this separate from checking whether the runtime selector has matches.

Or skip the browser setup

If your goal is to save a clean screenshot or PDF rather than extract DOM data with $$eval, ScreenshotNeo offers a one-request screenshot API. It does not execute arbitrary Puppeteer callbacks or return arrays of page data, so it is not a replacement for fixing a data-extraction query. For a capture, the cURL example is:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for request options. It accepts cookie or consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; those cleanup steps can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. It also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for AI agents. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

Sign up for ScreenshotNeo’s free plan to get 1,000 screenshots a month with no card.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Frequently Asked Questions

Does $$eval wait for a selector to appear?

No. It queries the page state at the time it runs; use a locator or an explicit wait when the target may not yet exist.

Can I use $$eval to click each matched element?

It is intended to query and return page-context data. For element interaction, use Puppeteer locators or the relevant interaction API.

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.