ScreenshotNeo

BlogHow-to

How to Expose Node.js Functions to a Page with Puppeteer

Use page.exposeFunction() to let page JavaScript call a narrow Node.js callback. Learn how it differs from page.evaluate(), how to handle errors, and how to troubleshoot the bridge.

By the ScreenshotNeo team4 October 20269 min read

page.exposeFunction(name, callback) is the Puppeteer API for letting JavaScript in a controlled page call a function that runs in Node.js. Register it on the Puppeteer side before page code needs it; the page calls it through window[name], and receives a Promise for the callback’s result. Use page.evaluate() instead when the work can run entirely inside the page.

This guide uses Puppeteer’s documented behavior: exposed functions run in Puppeteer’s Node.js context, returned Promises are awaited, and the installed functions survive navigations. Puppeteer API reference: exposeFunction().

1. Install Puppeteer and run a complete example

In an empty project, install Puppeteer:

npm init -y
npm install puppeteer

Save this as expose-function.mjs and run node expose-function.mjs. The example exposes a small hash function, calls it from the page, and closes the browser even if an operation fails.

import puppeteer from 'puppeteer';
import crypto from 'node:crypto';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();

  // Register the bridge before page JavaScript calls it.
  await page.exposeFunction('md5', (text) => {
    if (typeof text !== 'string') {
      throw new TypeError('md5 expects a string');
    }
    return crypto.createHash('md5').update(text, 'utf8').digest('hex');
  });

  // evaluate() runs in the browser page. The exposed function is on window.
  const result = await page.evaluate(async () => {
    return await window.md5('PUPPETEER');
  });

  console.log(result);
} finally {
  await browser.close();
}

The hash example demonstrates the bridge; it is not a recommendation to use MD5 for passwords or security-sensitive hashing. Use a modern password-hashing scheme for password storage.

In TypeScript, the API behavior is the same. If the project’s type checking does not know about your added window property, declare its real argument and return types in the project’s global Window interface. For example, for the callback above:

declare global {
  interface Window {
    md5: (text: string) => Promise<string>;
  }
}

Keep the declaration aligned with the actual callback. The bridge is asynchronous from page code, so represent it as a Promise there.

2. Understand which side each function runs on

page.evaluate(fn, ...args) serializes the function and evaluates it in the page context. The function cannot close over Node.js lexical variables or call helpers defined in the Puppeteer script. Pass serializable inputs as arguments. An exposed function is different: the name is installed on the page’s window, but its callback executes in Node.js.

Need API Where the code runs What crosses the boundary
Run a page-only calculation or inspect the DOM page.evaluate() Browser page Arguments in; serializable result out
Let page code request Node.js work page.exposeFunction() Callback in Node.js; callable name on page window Call arguments to Node.js; callback result back as a Promise
Set up page globals before the site’s scripts run page.evaluateOnNewDocument() Browser page Page-side setup; it is not a Node.js callback bridge

Puppeteer automatically awaits a Promise returned by page.evaluate(). Normal returned values are serialized back to Node.js; DOM nodes and other special objects do not become ordinary Node.js objects. Use page.evaluateHandle() when you need to retain a page object by reference. See the Puppeteer JavaScript execution guide.

// Correct: pass data needed by page.evaluate as arguments.
const label = 'Submit';
const buttonText = await page.evaluate((text) => {
  return `${text}: ${document.querySelector('button')?.textContent?.trim() ?? ''}`;
}, label);

// This does not work: page.evaluate's callback cannot close over `label`.
// const buttonText = await page.evaluate(() => label);

3. Expose asynchronous work and handle failures

The exposed callback may be synchronous or asynchronous. If it returns a Promise, Puppeteer waits for it and resolves the page-side Promise with the result. Page code should await that Promise when it needs the result and catch rejection where the page must recover.

await page.exposeFunction('lookupTitle', async (url) => {
  if (typeof url !== 'string') throw new TypeError('url must be a string');
  const parsed = new URL(url);
  if (!['https:', 'http:'].includes(parsed.protocol)) {
    throw new TypeError('Only HTTP and HTTPS URLs are accepted');
  }

  // Illustrative bounded Node.js operation. Apply your own allowlist and
  // network policy before making requests to user-provided destinations.
  const response = await fetch(parsed, { signal: AbortSignal.timeout(5000) });
  if (!response.ok) throw new Error(`Lookup failed: HTTP ${response.status}`);
  return response.headers.get('content-type');
});

const outcome = await page.evaluate(async () => {
  try {
    return { ok: true, value: await window.lookupTitle('https://example.com') };
  } catch (error) {
    return { ok: false, message: String(error) };
  }
});

In production, keep error details appropriate to the audience. A page-visible error can expose implementation details. Validate input in Node.js even if the page also validates it: the page is the caller, not a trusted boundary.

4. Choose a name, control its lifetime, and account for frames

  • Pick a specific name. The API adds the function to window. Avoid names likely to collide with site globals, and document the arguments and result the page may use.
  • Register before calling. Await page.exposeFunction() before navigation or evaluation can trigger the call.
  • Navigation: exposed functions survive navigations on that page, according to the API reference. Do not assume that a newly created, unrelated page has the same setup; expose functions on each page that needs them.
  • Remove when finished: call await page.removeExposedFunction(name) to remove a previously exposed function from the page’s window. See Puppeteer API reference: removeExposedFunction().
  • Frames and popups: reason about which page and frame runs the caller. Browser contexts isolate user storage, and a popup belongs to the context of its parent page. If another page needs a bridge, explicitly establish the required setup there. See Puppeteer BrowserContext reference.

To wait for page state rather than repeatedly calling from Node.js, use page.waitForFunction(). It evaluates a predicate in the page context and supports arguments and async functions; it is a wait mechanism, not a Node.js bridge. Puppeteer API reference: waitForFunction().

Use page.evaluateOnNewDocument() for page-context initialization that must happen after document creation but before the site’s scripts. Puppeteer documents that it runs on navigation and when child frames attach or navigate. It does not provide a Node.js callback callable by page code. Puppeteer API reference: evaluateOnNewDocument().

5. Secure the Node.js capability you expose

Any page script that can access the exposed name may be able to invoke its behavior. Treat the bridge as a capability boundary: the callback grants page JavaScript some ability to cause work in Node.js. Expose the smallest operation that solves the task, and validate every argument before using it.

  • Do not expose arbitrary shell execution, filesystem access, credentials, or unrestricted network requests to page code.
  • Prefer a fixed operation with a narrow input schema over a generic “run this” callback.
  • For URLs or file identifiers, validate protocol, host or path against an allowlist appropriate to the application. Consider server-side network access risks when accepting destinations from page content.
  • Limit input length and the amount of work a callback can trigger. Use timeouts and cancellation where the underlying operation supports them.
  • Return only the data the page needs. Avoid sending secrets or internal error traces to page JavaScript.
  • Remove the exposed name once the page no longer needs it, especially when reusing a page for different trust boundaries.

6. Troubleshoot common problems

Symptom Likely cause Fix
window.myFunction is not a function The function was never exposed on this page, the name differs, or the caller ran before registration completed. Await page.exposeFunction('myFunction', callback) on the same page before navigation/evaluation triggers the caller. Check spelling and page identity.
“Cannot find name” or undefined Node.js variable inside page.evaluate() The evaluated function runs in the browser context and cannot capture the Puppeteer script’s lexical scope. Pass data as an argument to evaluate(), move page-only logic into it, or expose a Node.js callback if the page must request Node.js work.
The result is [object Promise] or the page continues too early The page caller did not await the asynchronous exposed function. Use await window.myFunction(...) inside an async page function, or return the Promise from that function.
The page reports an unhandled rejection The Node.js callback rejected or threw, and the page caller did not handle the returned Promise. Catch the call in page code when recovery is needed; log and classify failures in Node.js. Check callback validation and downstream errors.
The callback works before navigation but seems absent on another tab The new tab is a separate Page instance that was not configured with the bridge. Expose the function on each page that requires it. Do not infer page setup from shared browser context storage.
A returned DOM element becomes an empty object Regular evaluation serializes return values; a DOM node is not a plain serializable value. Return the specific primitive data needed, or use page.evaluateHandle() to keep a reference to a page object.
The page can call a sensitive operation unexpectedly The exposed name is accessible to scripts running in that page. Narrow and validate the callback, remove it when no longer needed, and avoid loading untrusted scripts in a page with powerful exposed capabilities.

7. Performance, reliability, and cost

An exposed call crosses between browser page execution and the Node.js callback, so it is asynchronous. Avoid making many tiny round trips when the same work can be batched into one call or performed wholly in the page. The reviewed Puppeteer references do not provide a benchmark for this bridge; measure the workload and callback itself rather than assuming a fixed latency.

Reliability depends on the callback and the page lifecycle. Register before use, set timeouts for potentially slow external work, handle rejection on both sides where useful, and close the browser in a finally block. A navigation can change page state while a caller is running; design the operation so that stale results do not accidentally update the wrong application state.

Self-hosted Puppeteer has no per-screenshot API charge from the exposeFunction method itself, but running browsers consumes your own compute and operational time. If the task is simply to capture a URL and does not need a custom in-page Node.js bridge, a screenshot API can avoid browser installation and lifecycle management. ScreenshotNeo is a website screenshot API and MCP server; see ScreenshotNeo for its product overview.

8. Or skip the browser setup

If your goal is a screenshot rather than a custom Puppeteer bridge, ScreenshotNeo can capture a URL with one GET request. These examples use the documented endpoint and target URL; replace the placeholder API key with your own. See the ScreenshotNeo API documentation.

cURL

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

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
r.raise_for_status()
with open("shot.webp", "wb") as image:
    image.write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', image));

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the capture. Bot checks, blank pages, and failed loads are never billed; responses identify the page verdict and billing status in headers. Its MCP server lets AI agents use screenshot tools, and 1,000 screenshots per month are free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

9. FAQ

Can page.evaluate() directly call a Node.js function?

No. Its callback runs in the page context and cannot access Node.js lexical scope. Expose a callback with page.exposeFunction() when page code needs to invoke Node.js work.

Does the exposed function need to be awaited during registration?

Yes. Await page.exposeFunction() before page code can call the installed window function.

Can an exposed callback return an object or a Promise?

It can return a Promise, which Puppeteer awaits. Keep returned data serializable and limited to what the page needs.

How do I make Node.js setup run before a website’s JavaScript?

page.evaluateOnNewDocument() sets up code in the page context before site scripts. For callable Node.js work, use page.exposeFunction(); these APIs solve different problems.

Where can I confirm the method behavior?

Use the official Puppeteer exposeFunction API reference and its JavaScript execution guide.