How to Wait for a Stable Element Position in Puppeteer
Use Puppeteer locators to wait for a stable bounding box before an action, or wait for custom geometry with requestAnimationFrame polling.
For a click, fill, or hover, use a Puppeteer locator action directly: locator readiness waits for the element’s bounding box to remain stable over two consecutive animation frames. For a standalone wait, or when you need a different rule, use page.waitForFunction() with animation-frame polling and compare geometry across frames.
A stable position is a snapshot condition, not a guarantee that the page will never move the element later. Choose whether you need only the element’s x and y position to settle, or its full box—including width and height.
1. Prefer locator readiness before an action
If the next step is a supported locator action, let that action perform its readiness checks. Puppeteer documents stable bounding-box geometry over two consecutive animation frames as part of locator action readiness. This avoids a separate wait that may become stale before the action begins.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
// Locator actions perform readiness checks, including bounding-box stability.
await page.locator('button.submit').click();
} finally {
await browser.close();
}
The same principle applies to other locator actions such as fill() and hover(). Use the locator action when it is the real goal; wait for geometry explicitly when another operation depends on the settled position or when you need a custom condition.
2. Wait for a custom stable position
page.waitForFunction() repeatedly evaluates a function in the page context and resolves when the function returns a truthy value. With polling: 'raf', Puppeteer checks on animation frames, which is useful when styles or layout may be changing.
This runnable example waits for the element’s position to stay within a 0.5 CSS-pixel tolerance for three consecutive animation-frame samples. It checks x and y only. If dimensions must settle too, include width and height in the sampled values.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
const selector = '.target';
await page.waitForFunction(
(sel, tolerance, requiredSamples) => {
const element = document.querySelector(sel);
if (!element) {
// A missing or replaced element starts a fresh stability sequence.
window.__stablePositionState = null;
return false;
}
const rect = element.getBoundingClientRect();
const current = [rect.x, rect.y];
const state = window.__stablePositionState;
if (!state || current.some((value, i) => Math.abs(value - state.position[i]) >= tolerance)) {
window.__stablePositionState = { position: current, samples: 1 };
return false;
}
state.samples += 1;
state.position = current;
return state.samples >= requiredSamples;
},
{ polling: 'raf', timeout: 10_000 },
selector,
0.5,
3,
);
const position = await page.$eval(selector, element => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y };
});
console.log('Stable position:', position);
} finally {
await browser.close();
}
The sample stores scratch state on window to keep the predicate self-contained. In a page you do not control, that property could collide with application state. Use a unique property name, or use an explicit page.evaluate() and a requestAnimationFrame loop if avoiding page-global scratch state is important. The tolerance and sample count are application choices; Puppeteer’s documentation does not prescribe these values.
Include size when the full bounding box matters
Change the sampled array to include the dimensions:
const current = [rect.x, rect.y, rect.width, rect.height];
The comparison then requires all four values to remain within tolerance. If only position matters, comparing width and height can unnecessarily delay the wait while an element resizes in place.
3. Pick the right wait condition
| Need | Use | What it establishes |
|---|---|---|
| Perform a click, fill, or hover | Locator action | Action readiness, including stable bounding-box geometry across two consecutive animation frames |
| Wait until geometry itself is ready | waitForFunction() with a geometry predicate |
Your chosen dimensions, tolerance, and consecutive-sample requirement |
| Wait for an element to exist | waitForSelector() |
A matching element appears; visibility can also be requested |
| Wait for a CSS-driven change to settle | waitForFunction() with polling: 'raf' |
The predicate is evaluated on animation frames |
Element existence or visibility is not the same as geometric stability. A visible element can still move, and a stable element can later move again if the page changes.
4. Configure timeout, polling, and failure handling
The waitForFunction() options include a timeout and polling mode. The documented default timeout is 30 seconds in the referenced API; it can be configured for an individual wait or with page.setDefaultTimeout(). Check the API documentation matching the Puppeteer version installed in your project, since defaults and types are version-specific. An abort signal is also available in the documented options.
// Per-wait timeout and animation-frame polling
await page.waitForFunction(predicate, { polling: 'raf', timeout: 8_000 }, selector);
// Set the default timeout for applicable waits on this page
page.setDefaultTimeout(8_000);
A timeout should be treated as an expected failure path: report which selector and condition failed, then decide whether to retry, skip the operation, or fail the job. If the target disappears or is replaced during sampling, restart the sequence rather than counting measurements from different elements as a stable run. The example resets its sample state when the selector is absent; if your application replaces a node with another matching node, consider tracking the element identity as well.
5. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| The wait times out although the element is visible | The element keeps moving or resizing, or the predicate’s tolerance is too strict | Check which dimensions matter, inspect layout changes, and choose a tolerance that fits the page’s coordinate precision |
| The predicate errors because the element is missing | The selector has not matched yet, or the node was removed during the wait | Return false for a missing node and reset the consecutive-sample state |
| The wait resolves, then the element moves before the action | The stability check is only a momentary condition; later page activity shifted layout | Use the locator action directly when possible, or wait immediately before the dependent operation and account for known late content |
| A wait for visibility passes, but geometry is still changing | Visibility checks presence/display state, not stable position | Use a geometry predicate or a locator action readiness check |
| The predicate never becomes true for a fractional-pixel animation | Exact equality is too strict for continuously changing coordinates | Compare with a small tolerance rather than exact numeric equality |
| The script behaves differently across Puppeteer versions | Options or defaults differ from the API version consulted | Check the documentation for the installed package version and set timeout and polling explicitly |
6. Performance and reliability notes
- Prefer locator readiness over a fixed sleep. A sleep waits the same duration on fast and slow pages and does not prove that the element settled.
- Animation-frame polling is appropriate for visual geometry changes, but it runs in step with page rendering. Keep the predicate small: query the target, read its bounding box, compare values.
- Do not use a very large sample count as a substitute for understanding the page. A carousel, animation, sticky element, or late-loading image may never stay still for the chosen interval.
- Decide whether a CSS transform matters.
getBoundingClientRect()reports the rendered, viewport-relative rectangle, so scrolling or transforms can change the values even when document flow has not. - After navigation, scrolling, or a layout-changing action, the prior position may no longer apply. Wait again when the next operation depends on the new geometry.
- For recurring captures across many URLs, browser startup and page loading usually dominate a small predicate’s work. Reuse browser instances carefully in your own service and always close pages and browsers on success and failure.
7. Or skip the browser setup
If your goal is a screenshot rather than browser automation, ScreenshotNeo provides a website screenshot API. One GET request returns an image or PDF. Its capture flow removes cookie banners, newsletter popups, and chat widgets before the shot; bot checks, blank pages, and failed loads are never billed. It also offers an MCP server so AI agents can take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. Every feature is available on every plan.
See the ScreenshotNeo API documentation for options and response details.
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,
)
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}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));
Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.
8. FAQ
Does a stable position mean the element cannot move later?
No. It means the chosen measurements met the stability condition during the observed frames. Later content, animation, scrolling, or interaction can move the element again.
Should I compare the viewport position or document position?
getBoundingClientRect() gives viewport-relative geometry. For document coordinates, combine its position with the page’s scroll offset, and ensure scrolling itself is settled if it affects the result.
How many stable samples should I require?
Use the smallest count that meets your workflow’s needs. Locator readiness uses two consecutive animation frames for actions; a custom standalone wait can require a different number based on the page and operation.


