Puppeteer page.waitForFunction() never resolves even though condition becomes true

I am trying to wait for a dynamic value on a webpage before continuing execution in Puppeteer. The value updates asynchronously on the page through JavaScript, and I expected waitForFunction() to resolve once the value matches the expected value. However, the script keeps hanging until it times out.

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();
  await page.goto('https://example.com');

  await page.evaluate(() => {
    setTimeout(() => {
      document.body.setAttribute('data-status', 'ready'); // This updates after 2 seconds
    }, 2000);
  });

  console.log("Waiting for status to be 'ready'...");

  await page.waitForFunction(() => {
    return document.body.getAttribute('data-status') === 'ready';
  }, { timeout: 5000 }); // <-- never resolves, always times out 😕

  console.log("Condition met, continuing...");
})();

I can verify using DevTools that the attribute does update to “ready”, but Puppeteer still times out.

waitForFunction() executes inside the browser environment, so you must pass the function as a string or serializable closure. Sometimes Puppeteer fails to track DOM changes if the function references objects outside of its context.

Try this slightly rewritten version:

await page.waitForFunction(
  () => document.body.getAttribute('data-status') === 'ready',
  { polling: 'mutation', timeout: 5000 }
);

By using polling: ‘mutation’, Puppeteer listens for DOM changes instead of checking periodically, which makes it much more reliable for attribute updates. This version should resolve exactly when the attribute becomes ““ready””."

Sometimes, setTimeout runs before Puppeteer attaches listeners, meaning the condition is missed. Trigger the attribute update after navigation and ensure wait starts before update:

await page.goto('https://example.com');

// Start waiting before the value changes
const wait = page.waitForFunction(
  () => document.body.getAttribute('data-status') === 'ready',
  { timeout: 5000 }
);

// Trigger change
await page.evaluate(() => {
  setTimeout(() => {
    document.body.setAttribute('data-status', 'ready');
  }, 2000);
});

await wait;  
console.log("Condition met!");

This guarantees Puppeteer watches for the change before it actually happens, preventing race conditions.

If you want something more reliable than polling and don’t want to rely on waitForFunction, you can listen to changes directly in the DOM. This method never misses the event and works with any attribute change:

await page.evaluate(() => {
  return new Promise(resolve => {
    const observer = new MutationObserver(() => {
      if (document.body.getAttribute('data-status') === 'ready') {
        observer.disconnect();
        resolve(true);
      }
    });
    observer.observe(document.body, { attributes: true });
  });
});

console.log("Mutation detected,  status is ready!");

This approach is great if you’re waiting for changes triggered deep in async scripts or React apps.