ScreenshotNeo

BlogHow-to

Puppeteer Page Events: How to Listen for Page Changes

Learn how to listen for Puppeteer page events, track navigation and network activity, handle popups, and avoid common event-listener races.

By the ScreenshotNeo team4 October 20268 min read

Puppeteer’s Page object is an event emitter. Register a handler with page.on('eventName', handler), use page.once() for a one-time callback, and remove a persistent listener with page.off() and the same function reference. For example:

function logRequest(request) {
  console.log('Request:', request.url());
}

page.on('request', logRequest);
// Later:
page.off('request', logRequest);

This guide covers lifecycle events, URL and frame changes, popups, page-side errors, and network activity. Event names and payloads can vary across releases, so check the API reference for the Puppeteer version installed in your project. The examples use the current documented Page API patterns. See the Puppeteer Page API, Page event reference, and network logging guide.

1. Register, run once, and remove listeners

Use on for events that may happen repeatedly and once when the callback should run only for the next matching event. Keep a named function when you may need to unsubscribe; an equivalent new arrow function is not the same callback reference.

function onLoad() {
  console.log('The page fired load');
}

page.on('load', onLoad);
page.off('load', onLoad);

page.once('domcontentloaded', () => {
  console.log('The initial HTML document was parsed');
});

In TypeScript, give callbacks the relevant Puppeteer type when useful, and let the installed package’s declarations guide payload types:

import type { Page, HTTPRequest } from 'puppeteer';

function logRequest(request: HTTPRequest): void {
  console.log(request.method(), request.url());
}

function observe(page: Page): void {
  page.on('request', logRequest);
  // Remove this exact reference when observation ends.
  page.off('request', logRequest);
}

A listener registered after an event has already fired will not receive that past event. For navigation that an action is expected to trigger, register the wait before or at the same time as the action.

2. Listen for lifecycle events and navigation safely

load marks the page load event, while domcontentloaded marks parsing of the initial document. Neither guarantees that a single-page application has finished its own data fetching or rendering. Choose a wait condition that matches the state your task actually needs.

const page = await browser.newPage();

page.once('domcontentloaded', () => {
  console.log('DOM content loaded:', page.url());
});

await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
console.log('Navigation reached the DOM content loaded milestone');

To react to a click that navigates, start waiting for the navigation in parallel with the click. Waiting only after the click can miss a fast navigation:

const [response] = await Promise.all([
  page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
  page.click('a.next-page'),
]);

console.log('New URL:', page.url());
console.log('Navigation response status:', response?.status());

A URL change in a single-page application may use the History API and not cause a full document navigation. If you need to observe URL changes, watch frame navigation events and compare the URL reported by the frame. For application-specific route changes that do not produce a frame navigation, instrument or wait for the app’s own observable state.

3. Track frame changes and popups

The framenavigated event reports a Frame, not just the main document. Check frame === page.mainFrame() when you only care about the top-level page. Child frames can navigate independently; frames can also attach and detach.

page.on('framenavigated', frame => {
  const kind = frame === page.mainFrame() ? 'main frame' : 'child frame';
  console.log(kind, 'navigated to', frame.url());
});

page.on('frameattached', frame => {
  console.log('Frame attached:', frame.url());
});

page.on('framedetached', frame => {
  console.log('Frame detached:', frame.url());
});

A page’s popup event supplies the new popup’s Page. Attach the popup listener before the click that opens it:

const [popup] = await Promise.all([
  new Promise(resolve => page.once('popup', resolve)),
  page.click('a[target="_blank"]'),
]);

await popup.waitForLoadState?.('load');
console.log('Popup URL:', popup.url());

The optional waitForLoadState line above is not a Puppeteer API method. For Puppeteer, use a Puppeteer wait such as popup.waitForNavigation() only when the popup is expected to navigate after it is created, or listen for load before the opening action when appropriate. A safer popup example that does not assume a navigation after creation is:

const popupPromise = new Promise(resolve => page.once('popup', resolve));
await page.click('a[target="_blank"]');
const popup = await popupPromise;
console.log('Popup URL:', popup.url());

If the popup’s page content needs to be ready, register a lifecycle listener as soon as the popup is received; whether it will still fire depends on how quickly the popup loads. Avoid assuming every popup produces a later navigation event.

4. Capture console messages and page errors

The console event carries a Puppeteer console message object. The pageerror event reports uncaught exceptions from page JavaScript. These help distinguish browser-side application problems from navigation or network problems.

page.on('console', message => {
  console.log(`[browser:${message.type()}]`, message.text());
});

page.on('pageerror', error => {
  console.error('Uncaught page exception:', error);
});

Install diagnostic listeners before navigating if you need to capture messages emitted during startup. Console output is not a complete record of page behavior: caught exceptions, application logs sent elsewhere, and errors from a different page or worker may need separate handling.

5. Observe requests, responses, and completion

Puppeteer emits network events without request interception. Use them to observe traffic; do not enable interception merely to log it.

Event Meaning Typical payload
request The page issued a request. HTTPRequest
response A response arrived for a request. HTTPResponse
requestfinished The response body finished downloading and the request completed. HTTPRequest
requestfailed The request failed at the network or loading level. HTTPRequest
page.on('request', request => {
  console.log('>>', request.method(), request.url());
});

page.on('response', response => {
  console.log('<<', response.status(), response.url());
});

page.on('requestfinished', request => {
  console.log('Finished:', request.url());
});

page.on('requestfailed', request => {
  console.error('Failed:', request.url(), request.failure()?.errorText);
});

An HTTP 404 or 503 is still an HTTP response. It generally emits response and then requestfinished; it is not itself a requestfailed event. A redirect finishes one request and creates a new request to the redirected URL, so logs may show multiple request/response pairs for one navigation.

Do not assume every request has a response: a network error may fail before one arrives. Conversely, a successful response event does not mean its body has finished downloading; use requestfinished for completion.

6. Intercept requests only when you need to control them

With page.setRequestInterception(true), each intercepted request stalls until a handler continues, responds to, or aborts it, unless the browser cache completes it. Every path through your handler must resolve the request. If another listener may also resolve it, check immediately before acting.

await page.setRequestInterception(true);

page.on('request', request => {
  // Keep the handled check and resolution adjacent. Do not await between them.
  if (request.isInterceptResolutionHandled()) return;

  if (request.resourceType() === 'image') {
    void request.abort();
  } else {
    void request.continue();
  }
});

If asynchronous work is needed to decide what to do, recheck after that work, immediately before resolving:

page.on('request', async request => {
  const shouldBlock = await decideWhetherToBlock(request.url());

  if (request.isInterceptResolutionHandled()) return;
  if (shouldBlock) {
    await request.abort();
  } else {
    await request.continue();
  }
});

Request interception APIs and resolution behavior can depend on Puppeteer version and other installed handlers. Consult the official interception guide for the version you run. Avoid leaving a request unresolved: it can make navigation and page waits hang until timeout.

7. Common errors and fixes

Symptom Likely cause Fix
Listener never runs It was attached after the event fired, or the action did not emit that event. Register before navigation or trigger the action and wait together with Promise.all.
requestfailed is absent for a 404 HTTP error status is not a network failure. Inspect response.status() for HTTP errors; reserve requestfailed for load failures.
Navigation hangs after enabling interception A request was not continued, aborted, or answered. Ensure every request path resolves it and inspect competing handlers.
“Request is already handled” or resolution error Another listener resolved the intercepted request. Check isInterceptResolutionHandled() immediately before resolution.
Popup listener misses the new tab The click happened before the listener was registered. Register the popup wait before clicking.
Child frame navigation mistaken for page navigation framenavigated includes subframes. Compare the frame with page.mainFrame().
Listener runs multiple times A persistent on listener handles each event. Use once or remove the listener with its original callback reference.

8. Reliability, performance, and cleanup

  • Keep handlers quick. Heavy synchronous work delays event processing. Record the data needed and send expensive processing to a queue or later step.
  • Prevent unbounded logs. Network-heavy sites can emit many events. Filter by URL, resource type, or status and avoid retaining full request objects longer than needed.
  • Clean up listeners. Remove persistent callbacks when their task ends, especially in loops that reuse a page. Otherwise handlers accumulate and may duplicate work.
  • Use bounded waits. Pair event waits with timeouts or an enclosing job timeout so a missing event does not hang a worker indefinitely.
  • Choose the right readiness signal. networkidle0 and networkidle2 are navigation wait conditions based on active connection limits sustained for at least 500 ms. They are not Page event names, and long polling or analytics can make network-idle waits unsuitable.
  • Separate status from transport failure. Track HTTP status codes as well as failed requests if you need a reliable health picture.

For repeated captures, browser startup and page load usually dominate the small cost of registering a listener. Interception adds control and complexity; leave it off for passive observation. The cost of an event listener itself is local browser work, while external costs depend on how you run Chromium and how many pages or jobs you process.

9. When you need a screenshot instead of browser event handling

Puppeteer events are useful when your application needs to react to browser state. If the task is simply to get a clean image of a URL, ScreenshotNeo provides a screenshot API and MCP server, so you can request an image without setting up a browser and event listeners. See the ScreenshotNeo API documentation.

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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Its MCP server includes 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 screenshots. Every feature is on every plan.

Sign up for 1,000 free screenshots a month, with no card required.

10. FAQ

Is networkidle0 a Page event?

No. It is a navigation wait condition, not an event name to pass to page.on().

Does page.on('load') wait for a navigation?

No. It registers a callback for a future load event. Use a navigation wait alongside the action that triggers navigation when you need to await it.

Can I use the same handler for multiple pages?

Yes. Register the function on each Page and remove it from each Page with the same reference when finished.

Should I enable interception to watch requests?

No. Request and response events are available for observation by default. Enable interception only when you need to alter or block traffic.