The Tool Desk
Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
If Puppeteer code runs twice, first identify what is duplicated: an event listener, a navigation-triggered script, a browser page, or a request-interception callback. Each cause has a different fix. Register one-shot events with once, remove persistent listeners with off, coordinate clicks and navigation with Promise.all, clean up evaluateOnNewDocument scripts, audit every open page, and guard request interception synchronously.
Find the trigger before changing the code
Do not begin by adding arbitrary delays or restarting the browser. Add a run identifier and log the URL, frame, target and timestamp at every entry point. That tells you whether one callback is firing repeatedly or several independent workflows are running.
let runId = 0;
function mark(label, page) {
console.log(JSON.stringify({
run: ++runId,
label,
url: page.url(),
target: page.target()._targetId,
time: new Date().toISOString()
}));
}
Forward browser-side messages to Node so a page log cannot be mistaken for a second Node execution:
page.on('console', msg => {
console.log(`[browser] ${msg.type()}: ${msg.text()}`);
});
Run temporarily with headless: false and, if necessary, slow motion. Watching a reload, popup or redirect often reveals the second trigger immediately. The debugger can then show the exact call stack.
#1 Best Overall
When an event callback fires more than once
Understand on, once and off
page.on(type, handler) creates a persistent listener. Every later event invokes it, and every repeated setup call adds another copy. For a genuinely one-time action, use page.once; Puppeteer removes that listener after its first invocation.
await page.goto(startUrl);
page.once('load', () => {
console.log('This runs for the next load only');
});
For reusable setup, retain the same function reference and make installation idempotent:
function installLogging(page) {
const handler = msg => console.log('PAGE LOG:', msg.text());
page.off('console', handler); // removes this exact function if already present
page.on('console', handler);
return () => page.off('console', handler);
}
const removeLogging = installLogging(page);
// ...when the workflow ends:
removeLogging();
Calling off with a newly created anonymous function does not remove the old function. Keep the reference, or use removeAllListeners('console') only when you own every listener for that event.
Measure listener multiplication
console.log('request listeners:', page.listenerCount('request'));
console.log('console listeners:', page.listenerCount('console'));
console.log('load listeners:', page.listenerCount('load'));
Log counts immediately before and after your setup function. If a retry loop changes a count from one to two, move setup outside the loop or make it idempotent. Also inspect listeners on the browser context and browser object when those emitters are involved.
Prevent navigation races
A click can cause navigation before a separately awaited waitForNavigation() has registered. Puppeteer documents this race as a source of unexpected results. Start the wait and the action concurrently:
Rank #2
await page.goto(startUrl);
const [response] = await Promise.all([
page.waitForNavigation({waitUntil: 'domcontentloaded'}),
page.click('a.my-link'),
]);
console.log('arrived at', page.url(), 'status', response?.status());
The same pattern applies to form submission and any action that can replace the document. Do not write await page.click(...); await page.waitForNavigation(...) when the click itself starts navigation. If the action sometimes navigates and sometimes does not, use a timeout policy that matches the application and verify the resulting URL instead of blindly retrying the click.
Control scripts injected into every new document
evaluateOnNewDocument is intentionally invoked whenever the page navigates and whenever a child frame is attached or navigated. A script that increments a counter, patches APIs or installs a page listener will therefore run again by design. The usual mistake is registering the same hook repeatedly from a retry or per-navigation function.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
const injection = await page.evaluateOnNewDocument(() => {
window.__automationSetupCount = (window.__automationSetupCount || 0) + 1;
});
// ...when the hook is no longer needed:
await page.removeScriptToEvaluateOnNewDocument(injection.identifier);
Register long-lived hooks once per page (or once per context, according to your design), retain the returned identifier, and remove them during teardown. If a count rises once per navigation, inspect page.goto, reloads, redirects, history navigation and child frames before changing listener code.
Check whether multiple pages are doing the work
One browser can contain many pages, including popup tabs and pages created by separate browser contexts. A workflow that looks duplicated in logs may actually be running once in each page.
const pages = await browser.pages();
if (pages.length !== 1) {
throw new Error(`Expected one page, found ${pages.length}`);
}
const page = pages[0];
Use that guard as a diagnostic, not as a universal production rule. Multi-tab applications are legitimate. For a complete audit, inspect:
browser.pages()for every currently open tab.browser.targets()for page, worker and other targets.- Every
browser.newPage()call, including calls inside loops and retry handlers. - Popup handling such as
page.waitForEvent('popup')and newly created contexts. - Whether a test runner starts the same fixture or job more than once.
Log each page’s URL, target identifier and creation site. Close pages you no longer own, and pass an explicit page object to worker functions rather than letting each function discover or create its own page.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Guard request interception against duplicate resolution
With request interception enabled, more than one handler can see the same request. An asynchronous handler can also yield after another handler has already continued, aborted or responded. Before every resolution, check request.isInterceptResolutionHandled() synchronously.
await page.setRequestInterception(true);
page.on('request', async request => {
if (request.isInterceptResolutionHandled()) return;
const shouldBlock = request.resourceType() === 'image';
if (shouldBlock) {
request.abort();
return;
}
await doAsyncDecision(request.url());
// Another handler may have resolved it while we awaited.
if (request.isInterceptResolutionHandled()) return;
request.continue();
});
Make the check immediately before abort, continue or respond, not only at the beginning. If several modules intercept requests, consolidate them or establish clear ownership so exactly one module resolves each request.
Use a trigger-based diagnostic decision tree
| Observed pattern | Inspect first | Most direct fix |
|---|---|---|
| Once per navigation | evaluateOnNewDocument, reloads, redirects, history and child frames |
Register the hook once and remove it with its identifier |
| Once per event | Every page.on call, listener counts and retry paths |
Use once for one-shot work; otherwise retain a handler and call off |
| Once per tab | browser.pages(), targets, popups, newPage() and contexts |
Assign page ownership and close or reject unexpected pages |
| After a click | Separate click and navigation awaits | Use Promise.all([page.waitForNavigation(), page.click()]) |
| During interception | Multiple handlers and awaits before resolution | Check isInterceptResolutionHandled() before and after async work |
A robust setup skeleton
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();
let nextRun = 0;
const logHandler = msg => console.log('PAGE LOG:', msg.text());
page.on('console', logHandler);
const injection = await page.evaluateOnNewDocument(() => {
window.__automationSetupCount = (window.__automationSetupCount || 0) + 1;
});
try {
const pages = await browser.pages();
if (!pages.includes(page)) throw new Error('Owned page is no longer open');
console.log('run', ++nextRun, 'url', page.url());
await page.goto('https://example.com', {waitUntil: 'domcontentloaded'});
const [response] = await Promise.all([
page.waitForNavigation({waitUntil: 'domcontentloaded'}),
page.click('a'),
]);
console.log('navigation status', response?.status());
} finally {
await page.removeScriptToEvaluateOnNewDocument(injection.identifier);
page.off('console', logHandler);
await browser.close();
}
Adapt the navigation step to your site; the important properties are explicit ownership, one installation of each long-lived hook, concurrent navigation waiting, and cleanup in finally.
Performance, reliability and cost considerations
Do not use delays as a duplicate fix
waitForTimeout can hide a race on one machine and fail on another. Prefer lifecycle signals such as domcontentloaded, a specific selector, or network-idle conditions appropriate to the page. A delay is useful only when the application genuinely needs a known settling interval.
Rank #4
Keep retries from multiplying work
Place retry counters around the operation, not around listener and injection registration. On retry, either reuse the same page and handlers or tear them down completely before installing new ones. Log an operation ID so a legitimate retry is distinguishable from an accidental duplicate.
Separate browser and page scopes
Browser-level initialization belongs near browser creation; page-level listeners belong in a page factory that runs exactly once per page. Frame-specific behavior should identify frame.url() and avoid assuming that a child-frame navigation is a new top-level workflow.
Common errors and fixes
“My listener count keeps increasing”
Setup is being called in a loop, retry, test hook or navigation callback. Move it to one initialization path, retain the handler reference, and remove it during teardown.
“The click runs twice after a timeout”
The first click may have succeeded while the navigation wait timed out. Check the URL and page state before retrying. Use the concurrent Promise.all pattern and make the action idempotent where possible.
Free tools Windows power users keep installed
One-click scans. No signup required.
“The injected script runs on every reload”
That is the documented behavior of evaluateOnNewDocument. Count invocations separately from registrations; register once, and remove the script when the workflow ends.
Best Value
“Request is already handled”
Another interception handler resolved it during an asynchronous gap. Check isInterceptResolutionHandled() immediately before and after every await, then resolve only when the request is still pending.
“There is a second page I did not create”
A popup, redirect target, test fixture or context loop may have created it. Print browser.pages() and browser.targets(), attach ownership metadata, and close unexpected pages deliberately.
Or skip the browser setup
If your actual goal is a clean website image rather than browser automation, ScreenshotNeo provides a single request. It accepts cookie and consent banners like a visitor, removes more than 60 known consent platforms plus newsletter popups and chat widgets before capture, and reports whether a response was billed. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed.
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 documentation for all options, including full-page and element captures, device and retina settings, PDF output, custom CSS and JavaScript, waits, blocking, headers, cookies, geolocation, caching, signed links, asynchronous jobs, bulk capture and the usage API.
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 also offers an MCP server with take_screenshot, get_page_info and capture_pdf for Claude, Cursor and other MCP clients. The Free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 shots. Sign up free for ScreenshotNeo.
Frequently Asked Questions
Should I use removeAllListeners() whenever Puppeteer duplicates a callback?
Only if your code owns all listeners for that event. Otherwise retain the specific handler and call off; removing unrelated listeners can break other parts of the workflow.
Does a child-frame navigation count as a new document for injected scripts?
Yes. evaluateOnNewDocument scripts run when a child frame is attached or navigated as well as when the top-level page navigates.
Is one Puppeteer page guaranteed per browser?
No. A browser can contain multiple pages, popups and contexts. Inspect browser.pages() and browser.targets() before assuming a duplicate execution came from one page.
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.

