How to Trigger Lazy Loading Without Moving the Page in Puppeteer
Puppeteer has no universal no-scroll command for lazy content. Inspect the site's loader, use site-specific instrumentation, and verify the viewport stays put.
Direct answer: Puppeteer has no documented universal command that makes every lazy loader fetch offscreen content while guaranteeing that the page does not move. If a site uses IntersectionObserver, you can inspect or instrument its observer callbacks from page JavaScript, but manually invoking callbacks is a site-specific workaround and may not reproduce genuine visibility or other loading conditions.
For dependable results, identify the site’s loading mechanism, try an approach that does not scroll, then verify both that the expected content appeared and that window.scrollY stayed unchanged. If the loader requires actual viewport visibility, a no-movement trigger may not be possible; scrolling and restoring can preserve the final position, but it does move the page during capture.
Why lazy loading usually needs visibility
A common pattern is to defer an image or component until it approaches or enters the viewport. IntersectionObserver reports changes in the intersection between a target and a root, often the browser viewport. Google’s guidance describes lazy-loaded content as loading when it becomes visible in the viewport. When the target is far below the viewport and the page is not scrolled, there may be no genuine intersection change to trigger.
That leaves two different goals:
- Keep the viewport fixed throughout: inspect the page and try site-specific JavaScript. There is no general guarantee this will satisfy the page’s loader.
- Keep the final viewport position fixed: scroll through relevant content, wait for loading, then restore the original position. This changes the viewport temporarily and can affect scroll-triggered behavior.
Use Puppeteer’s evaluation APIs for inspection and instrumentation. Be careful with interactions: Puppeteer documents that hover() and click() can scroll a target into view when needed, so they are not no-movement substitutes.
Inspect and verify without scrolling
This Node.js example records the scroll position, inventories images, checks whether a target image has loaded, and verifies that the viewport did not move. It does not force a lazy loader; it gives you a baseline before applying site-specific logic.
import puppeteer from 'puppeteer';
const url = 'https://example.com/page';
const targetSelector = 'img[data-src]';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded' });
const before = await page.evaluate(() => ({
scrollY: window.scrollY,
images: [...document.images].map((img) => ({
src: img.currentSrc || img.src,
dataSrc: img.getAttribute('data-src'),
loading: img.loading,
complete: img.complete,
naturalWidth: img.naturalWidth,
rectTop: img.getBoundingClientRect().top
}))
}));
console.log('Before:', before);
// Add site-specific inspection or instrumentation here.
// Do not assume an offscreen image has loaded just because the script ran.
const after = await page.evaluate((selector) => {
const img = document.querySelector(selector);
return {
scrollY: window.scrollY,
found: Boolean(img),
src: img?.currentSrc || img?.src || null,
dataSrc: img?.getAttribute('data-src') || null,
complete: img?.complete ?? null,
naturalWidth: img?.naturalWidth ?? null
};
}, targetSelector);
console.log('After:', after);
if (after.scrollY !== before.scrollY) {
throw new Error(`Scroll position changed: ${before.scrollY} -> ${after.scrollY}`);
}
if (!after.found || !after.naturalWidth) {
console.warn('Target image is absent or not decoded; inspect this site’s loader.');
}
} finally {
await browser.close();
}
Replace the example URL and selector with the page under investigation. For an application that uses a different attribute or a background image, inspect the relevant element and its loading state instead of relying on document.images.
Use early instrumentation when you know the loader
page.evaluateOnNewDocument() runs code before the page’s own scripts execute on navigation. This makes it useful for recording or wrapping browser APIs early. The following diagnostic wrapper records observed targets and callback entries, then logs them after navigation. It preserves the callback invocation, but it does not make offscreen elements intersect, force network requests, or guarantee that application code will load them.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
window.__intersectionLog = [];
const NativeObserver = window.IntersectionObserver;
if (!NativeObserver) return;
window.IntersectionObserver = class extends NativeObserver {
constructor(callback, options) {
super((entries, observer) => {
window.__intersectionLog.push(entries.map((entry) => ({
tag: entry.target.tagName,
id: entry.target.id,
className: typeof entry.target.className === 'string' ? entry.target.className : '',
isIntersecting: entry.isIntersecting,
ratio: entry.intersectionRatio
})));
callback(entries, observer);
}, options);
}
};
});
await page.goto('https://example.com/page', { waitUntil: 'domcontentloaded' });
const log = await page.evaluate(() => window.__intersectionLog || []);
console.log(log);
} finally {
await browser.close();
}
The wrapper is diagnostic and implementation-dependent. A site may capture the native constructor before your wrapper runs, use a different loader, or require additional state such as a data attribute, a successful request, or component-specific code.
Site-specific ways to request the content
After inspecting the page’s scripts and DOM, use the narrowest mechanism that matches its implementation:
- Native image lazy loading: check for
loading="lazy", the image’s actualsrc, and whether the browser has completed and decoded it. Changing attributes can prompt a request on some pages, but behavior depends on the browser and site; verify the result. - Data-attribute loaders: some pages keep the URL in
data-srcor another attribute until their own code copies it intosrc. Updating the attribute alone may do nothing if the site’s observer callback is responsible for the copy. - Framework components: the loader may depend on component state, route data, or a framework lifecycle. Prefer the application’s own loading mechanism when available; changing DOM attributes behind the framework can leave its state inconsistent.
- IntersectionObserver-based code: you can study the callback and, as a controlled workaround, invoke application-specific logic if it is accessible. Manually calling an observer callback with fabricated entries is not equivalent to a browser-generated visibility event and can miss root bounds, thresholds, or other conditions.
- Scroll-triggered handlers: if the site listens for scroll events or computes geometry directly, an observer wrapper may not help. A synthetic event is not necessarily equivalent to actual movement and may be ignored by code that checks layout.
Do not use hover() or click() on an offscreen target when preserving the viewport is required: those actions can scroll the target into view. Likewise, avoid assuming that dispatching an event or invoking a callback means content has loaded. Check the rendered state and, when relevant, the request and image decode state.
Scroll and restore when final position matters
If the task only requires the page to end at its original location, a controlled scroll through the document may activate real visibility-driven loading. This does not meet a strict requirement that the page never move, and pages with sticky elements, infinite scrolling, or scroll handlers can behave differently afterward.
const originalY = await page.evaluate(() => window.scrollY);
await page.evaluate(async () => {
const step = Math.max(200, Math.floor(window.innerHeight * 0.75));
for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
window.scrollTo(0, y);
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
// Wait for the specific content or request condition your page needs.
await page.waitForFunction(() => {
const img = document.querySelector('img[data-target-image]');
return Boolean(img?.complete && img.naturalWidth > 0);
}, { timeout: 10000 }).catch(() => {});
await page.evaluate((y) => window.scrollTo(0, y), originalY);
const finalY = await page.evaluate(() => window.scrollY);
console.log({ originalY, finalY });
Adapt the wait condition to the page. A fixed delay alone is not proof that all content has loaded, and document.documentElement.scrollHeight can grow as an infinite-scroll page appends content. Bound the traversal or stop when the relevant targets are ready to avoid an unending loop.
Options and trade-offs
| Approach | Viewport movement | What it can do | Main limitation |
|---|---|---|---|
Inspect with page.evaluate() |
No inherent movement | Read DOM, attributes, geometry, and state | Inspection does not trigger every loader |
| Instrument before navigation | No inherent movement | Observe or wrap APIs such as IntersectionObserver |
Site may use another mechanism or require genuine intersection |
| Invoke site-specific loading logic | Usually none | Can request content if the application exposes a usable hook | Not portable; may bypass state or required conditions |
| Scroll and restore | Moves temporarily | Can create genuine visibility changes | Can trigger sticky, scroll, or infinite-loading behavior |
Reliability, performance, and capture considerations
- Wait for a condition, not just elapsed time. Prefer checking the target’s loaded state or an application-specific readiness signal. A timeout can cap waiting, but it does not prove success.
- Verify at the end. Check both the expected content and
scrollY. For images,completealone can also be true for a failed image; checknaturalWidth > 0. - Keep instrumentation narrow. Wrapping a global API can affect all page code and may alter behavior. Limit it to diagnosis, preserve the original callback and options, and compare results with instrumentation disabled.
- Account for layout shifts. Loading images or fonts can change page geometry even when
scrollYremains constant. If the goal is a stable screenshot, verify the target layout as well as the scroll offset. - Bound work. Large pages and infinite scrolling can generate many requests and consume time and memory. Load only the content required by the capture or extraction task.
- Use the right wait lifecycle.
domcontentloadeddoes not mean lazy resources are ready. Network-idle states can also be unsuitable for pages with long-lived requests. Wait on the target condition that matters.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Offscreen image remains blank | It has not intersected the viewport, or its loader requires another condition | Inspect src, data attributes, observer setup, and requests; use the site’s own loading path or accept that real visibility may be required |
| Observer wrapper records nothing | The page does not use that API, captured the constructor earlier, or has not registered targets | Install before navigation with evaluateOnNewDocument(); inspect alternate loader mechanisms |
| Callback ran but no image appeared | A fabricated callback did not satisfy the site’s conditions, or the request failed | Check application state and network outcome; do not treat callback execution as proof of loading |
| Scroll position changed unexpectedly | An interaction scrolled a target into view, or page code adjusted scrolling | Remove offscreen click/hover actions; record and compare scrollY around each operation |
| Image says complete but is blank | The request may have failed; completion alone is insufficient | Check naturalWidth, current URL, and request failure state |
| Scroll-and-restore misses content | Traversal was too fast, height changed, or the page loads only after more specific conditions | Use bounded steps and target-based waits; re-read document height as needed and verify required elements individually |
| Position is restored but screenshot differs | Lazy loading caused layout shifts or scroll-triggered UI changes | Wait for layout stabilization and inspect sticky elements, animations, and page state after restoration |
Or skip the browser setup
If you need a screenshot rather than a custom Puppeteer browser workflow, ScreenshotNeo provides a website screenshot API and MCP server. Its capture options include full-page shots with lazy images loaded. Cookie banners, popups, and chat widgets are removed before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are never billed, and response headers identify the page verdict and billing status. AI agents can use its MCP server tools to take screenshots, get page information, and capture PDFs.
One GET request returns the screenshot. See the ScreenshotNeo API documentation for request options.
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}`);
await Bun.write('shot.webp', res);
There is a free allowance of 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots. Sign up for free and get 1,000 screenshots a month with no card.
FAQ
Does page.evaluate() wait for asynchronous work?
Yes. Puppeteer waits for a returned promise to resolve. The page function still needs to return a meaningful completion condition; starting an asynchronous request without awaiting it does not make that work complete before evaluation returns.
Can I guarantee the page never moves?
You can avoid Puppeteer interactions that scroll targets into view and compare scrollY before and after your script. A site can also adjust scroll position itself, and a no-scroll script cannot guarantee that a visibility-dependent loader will run.
Does a full-page screenshot prove every lazy item loaded?
No. Check the relevant images or content directly. A full-page capture is not, by itself, evidence that a particular site’s loader completed.


