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

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:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.

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.

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

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:

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.

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

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.

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

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.

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

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.

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

“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.

“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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.

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

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.

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.