Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
Call page.exposeFunction() first, await it, and only then inject the script with page.addScriptTag(). Puppeteer places the exposed name on the page’s window; when page code calls it, Puppeteer invokes your Node.js callback and returns a promise for its result.
Working example: expose, then inject
This complete Node.js example registers a bridge named lookupValue before adding a script. The callback runs in Node.js, while the injected code runs in the browser page.
const puppeteer = require('puppeteer');
async function lookupInNode(key) {
// Replace this with database, filesystem, or service work.
return { key, value: `value-for-${key}` };
}
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.exposeFunction('lookupValue', async key => {
return await lookupInNode(key);
});
await page.addScriptTag({
content: `
(async () => {
const result = await window.lookupValue('example');
console.log('Node.js returned:', result);
})();
`
});
await page.waitForTimeout(1000);
await browser.close();
})();
The important sequencing is the two awaited calls: await page.exposeFunction(...) completes before page.addScriptTag(...) starts code that references the bridge. The callback may be asynchronous; Puppeteer waits for the promise it returns and serializes the resolved value back to the page.
Recommended Free Tools
What the page actually sees
Use window.lookupValue(...) in injected code. It is a promise-returning browser-side function, so use await when subsequent work needs the result. Keep returned data serializable (plain objects, arrays, strings, numbers, booleans, or null). Handle errors in the Node callback and in the injected script so a rejected operation does not become an unexplained page error.
#1 Best Overall
await page.addScriptTag({
content: `
(async () => {
try {
const data = await window.lookupValue('customer-42');
document.body.dataset.lookup = JSON.stringify(data);
} catch (error) {
console.error('Bridge failed', error);
}
})();
`
});
How exposeFunction() and addScriptTag() differ
page.exposeFunction(): Node.js to page
exposeFunction(name, callback) installs a named function on the page’s window. Calling that function crosses from the page context into Node.js, runs your callback, and resolves a promise with the callback’s result. Register each name only after the page exists and before dependent page code executes.
page.addScriptTag(): add a script element
addScriptTag() adds a script element to the main frame. Supply either content for inline source or a url for an external script.
await page.addScriptTag({ url: 'https://cdn.example.test/widget.js' });
await page.addScriptTag({ content: 'window.lookupValue("from-inline");' });
An external script must be able to load in the page’s security context. Inline code must still wait for the bridge if it calls the exposed function immediately; awaiting the exposure registration is the reliable ordering rule.
Choosing the right Puppeteer mechanism
Use addScriptTag() for a script added to the current page
Choose it when the page has navigated (or is otherwise ready) and you want to add a URL or a block of source. Expose every Node callback first, then add the tag.
Rank #2
Use page.evaluate() for a one-off operation
evaluate() executes a supplied function directly in the page context, accepts arguments, and waits for a promise returned by that function. It is ideal when you control the operation from your Node script and do not need an arbitrary injected script to call a reusable bridge.
const title = await page.evaluate(() => document.title);
const doubled = await page.evaluate(value => value * 2, 21);
evaluate() does not itself publish a persistent Node.js callback on window. If page-side code needs to call Node repeatedly, use exposeFunction().
Use evaluateOnNewDocument() when setup must precede site scripts
For a hook that must exist before the site’s own scripts run, register a new-document script. Puppeteer runs it after document creation and before page scripts, including documents created when the page navigates and when child frames attach or navigate.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
const preloadId = await page.evaluateOnNewDocument(() => {
window.siteStartTime = Date.now();
});
await page.goto('https://example.com');
This mechanism addresses a different timing requirement from addScriptTag(): it is a preload registration, not a script element added to an already available document.
Frames: expose and inject in the intended context
Puppeteer models iframes as separate Frame contexts. JavaScript evaluated in one frame does not automatically modify nested frames. Because page.addScriptTag() is a shortcut for the main frame, do not assume that adding a tag to the page changes an iframe.
Target an iframe explicitly
const frame = page.frames().find(f => f.url().includes('/embedded-widget'));
if (!frame) throw new Error('Target iframe was not found');
await frame.addScriptTag({
content: `
(async () => {
const value = await window.lookupValue('inside-frame');
console.log(value);
})();
`
});
Register the exposed function before the frame’s dependent script runs. If the frame navigates, treat the resulting document as a new execution context and arrange your preload or injection timing accordingly.
Cleanup and lifecycle management
Remove an exposed bridge
await page.removeExposedFunction('lookupValue');
Remove bridges when a page is being reused for unrelated jobs or when the callback captures resources that should be released. Do not remove it while an injected operation still depends on it.
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 →Remove a new-document preload
const identifier = await page.evaluateOnNewDocument(() => {
window.bootstrapFlag = true;
});
// Later, when the registration is no longer needed:
await page.removeScriptToEvaluateOnNewDocument(identifier);
Keep the identifier returned by evaluateOnNewDocument(); the removal method needs that identifier, not the function body.
Rank #4
Reliable patterns for production scripts
Make registration idempotent in your application
A page or frame can be revisited by a worker. Track whether your application has installed a bridge for that page, and avoid racing two setup paths that use the same name. If a callback must be replaced, remove the old registration deliberately before installing the new one.
Validate arguments at the Node boundary
Injected JavaScript is page-controlled code. Check types, lengths, allowed operations, and authorization in the Node callback before touching files, databases, or network services. Never treat a value supplied by the page as a trusted server-side path or command.
Return small, serializable results
Return the fields the page needs rather than handles to Node objects. Convert dates, errors, and other non-plain values into explicit serializable representations. For large data, consider a separate application endpoint instead of moving a large payload through the bridge.
Wait for the operation, not only script insertion
addScriptTag() confirms that the tag was added, but an immediately started asynchronous function may still be running. Have the injected script signal completion (for example, by setting a known DOM attribute), then wait for that signal from Node.
Best Value
await page.addScriptTag({
content: `
(async () => {
const result = await window.lookupValue('job-7');
document.documentElement.dataset.bridgeDone = JSON.stringify(result);
})();
`
});
await page.waitForFunction(() => 'bridgeDone' in document.documentElement.dataset);
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Troubleshooting
“window.lookupValue is not a function”
- Cause: the script ran before exposure completed, or it ran in a different frame.
- Fix: await
page.exposeFunction()before injection, and use the target frame’saddScriptTag()or evaluation context.
The callback never runs
- Cause: the injected code did not execute, an external URL failed to load, or an exception occurred before the call.
- Fix: capture page console and page errors, use inline
contentto isolate URL loading, and wrap the page call intry/catch.
The result is empty or cannot be serialized
- Cause: the callback returned an unsupported object or an unresolved operation.
- Fix: await the Node work and return plain serializable data; map errors to strings or structured error objects.
It works on the main page but not in an iframe
- Cause: frame execution contexts are separate.
- Fix: locate the intended
Frame, inject there, and account for frame navigation.
Site code runs before your setup
- Cause: a script tag was added after the page’s startup scripts.
- Fix: move required setup to
evaluateOnNewDocument(), retaining its identifier for later cleanup.
Or skip the browser setup
If your goal is a clean image or PDF rather than custom Puppeteer orchestration, ScreenshotNeo provides a single HTTP request. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be disabled. 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. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.
Use the API documentation at https://screenshotneo.com/docs/ for the complete option list, including full-page and selector captures, device and retina settings, PDF controls, custom CSS and JavaScript, waits, request blocking, headers, cookies, geolocation, caching, signed links, asynchronous webhooks, bulk capture, usage data, and OpenAPI compatibility.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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}`);
There are 1,000 screenshots per month free with no card. Paid plans start at $5 for 3,000 screenshots; every feature is included on every plan. Create a free ScreenshotNeo account to begin.
Frequently Asked Questions
Can an exposed function be called from an external script URL?
Yes. Register the bridge first, then add the URL with page.addScriptTag({url}). The external script must call the name in the same frame where it was added.
Does exposing a function survive navigation?
Treat navigation as a new document and verify your setup timing. For code that must be present before every page script, use a new-document preload and remove it with its returned identifier when finished.
Can I expose more than one function?
Yes. Expose distinct names for distinct operations, and validate every argument at the Node.js boundary.
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.
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 →

