How to wait for one of several elements with Puppeteer

I’m working on a web scraping project where I need to handle different possible outcomes from a search form. After submitting the form, the page might show search results or display an error message saying no data was found.

The problem is that I don’t know which element will appear first. I tried using try-catch blocks but this approach is really slow and unreliable:

try {
    await page.waitForSelector('.results-container', {timeout: 2000});
} catch(error) {
    await page.waitForSelector('.no-data-message');
}

Is there a better way to wait for multiple possible selectors and detect which one appears? The content loads via AJAX so regular navigation waiting doesn’t work here.

there’s actually a cleaner way - use puppeteer’s waitForFunction to check for multiple elements at once. just pass a function that returns whichever element exists:

const element = await page.waitForFunction(() => {
  return document.querySelector('.results-container') || document.querySelector('.no-data-message');
});

then check the className to see which one appeared. works great for ajax stuff and skips all the promise complications.

Use Promise.race() with multiple waitForSelector calls. It’ll resolve as soon as any selector shows up on the page.

const result = await Promise.race([
    page.waitForSelector('.results-container').then(() => 'results'),
    page.waitForSelector('.no-data-message').then(() => 'error')
]);

if (result === 'results') {
    // handle search results
} else {
    // handle no data scenario
}

Way more efficient than try-catch since you’re not waiting for timeouts. All selectors get monitored at once, and the promise resolves the moment any element appears. I’ve used this tons of times for AJAX-heavy scraping - it’s faster and way more reliable than waiting sequentially.

honestly just wrap everything in a single try-catch with a longer timeout and check both selectors after:

try {
    await page.waitForSelector('.results-container, .no-data-message', {timeout: 5000});
    const hasResults = await page.$('.results-container');
    // handle accordingly
} catch(e) {
    // neither appeared
}

way simpler than promises and works fine for most cases imo

I’ve hit this same issue tons of times. Best approach I’ve found is combining waitForResponse with element checking for AJAX stuff. Don’t just wait for elements - monitor the actual network request that updates the content:

const response = await page.waitForResponse(response => 
    response.url().includes('/search-endpoint') && response.status() === 200
);

// Now check which element appeared
const hasResults = await page.$('.results-container');
if (hasResults) {
    // process results
} else {
    // handle error case
}

This way the AJAX call finishes before you check elements, so no more timing issues. Element checking becomes way more reliable since you know the server’s done processing.

Everyone’s suggesting Puppeteer solutions, but you’re overcomplicating this. Web scraping gets messy when you’re dealing with multiple selectors and timing issues.

I had the same problem scraping job boards - sometimes you get listings, sometimes “no results” pages. Instead of fighting Puppeteer’s quirks, I built a workflow that handles the element waiting automatically.

You set up your scraping flow once and it handles all the conditional waiting. No Promise.race headaches or polling functions. Just tell it which elements to watch for and what to do when they appear.

When you need to scale across multiple search forms or add new conditions, you don’t rewrite a bunch of brittle Puppeteer code. The workflow adapts.

I use Latenode for this automation. Much cleaner than managing edge cases manually: https://latenode.com

I hit this exact problem scraping e-commerce sites where searches either showed results or “no products found” messages. Best solution I found was using page.waitForSelector with multiple selectors, then checking which one exists:

const selector = await page.waitForSelector('.results-container, .no-data-message');
const elementClass = await page.evaluate(el => el.className, selector);

if (elementClass.includes('results-container')) {
    // process results
} else {
    // handle no data
}

This kills race conditions completely - waitForSelector returns whichever element shows up first. You get speed from monitoring multiple selectors at once, plus direct access to the element that triggered. Way cleaner than separate queries after the fact since you already have the element reference.

Here’s another approach that works great - use page.evaluate to poll for elements directly in the browser. I’ve found this super useful for dynamic content that loads at unpredictable speeds:

const foundElement = await page.evaluate(() => {
  return new Promise((resolve) => {
    const checkElements = () => {
      const results = document.querySelector('.results-container');
      const noData = document.querySelector('.no-data-message');
      
      if (results) {
        resolve({type: 'results', element: results});
      } else if (noData) {
        resolve({type: 'no-data', element: noData});
      } else {
        setTimeout(checkElements, 100);
      }
    };
    checkElements();
  });
});

This runs entirely in the browser so there’s no communication overhead between Node and the browser for each check. You can adjust the polling interval based on what you need. Works great when elements might appear and disappear quickly during AJAX transitions.