DEV Community

Vitalii Holben
Vitalii Holben

Posted on

Handling Lazy-Loaded Content in Automated Screenshots

You set up Puppeteer, navigate to a page, call page.screenshot(), and the bottom half of your image is blank placeholder boxes. Welcome to lazy loading.

Most modern sites defer images and heavy content until the user scrolls. Your headless browser never scrolls. So those elements never load.

Here's how to deal with it.

The scroll trick

The most common fix is to programmatically scroll down the page before taking the screenshot:

async function scrollToBottom(page) {
  await page.evaluate(async () => {
    const delay = ms => new Promise(r => setTimeout(r, ms));
    const distance = 300;

    while (window.scrollY + window.innerHeight < document.body.scrollHeight) {
      window.scrollBy(0, distance);
      await delay(150);
    }

    window.scrollTo(0, 0);
  });
}

await page.goto("https://example.com", { waitUntil: "networkidle2" });
await scrollToBottom(page);
await page.waitForTimeout(1000);
await page.screenshot({ fullPage: true });
Enter fullscreen mode Exit fullscreen mode

The 150ms delay between scrolls gives IntersectionObserver-based lazy loaders time to trigger. Too fast and you'll scroll past elements before they start loading.

That final waitForTimeout after scrolling back to top lets any remaining images finish rendering. Not elegant, but necessary.

Why networkidle2 isn't enough

You'd think waitUntil: "networkidle2" would handle this. It waits until there are no more than 2 network connections for 500ms. But lazy-loaded images haven't even been requested yet at that point — they're waiting for a scroll event that never happens.

networkidle2 only helps with content that loads on page init. For scroll-triggered content, you need the scroll.

The loading="eager" override

Some sites use the native loading="lazy" attribute. You can override it before images load:

await page.evaluateOnNewDocument(() => {
  Object.defineProperty(HTMLImageElement.prototype, "loading", {
    set: function(val) { this.setAttribute("loading", "eager"); },
    get: function() { return "eager"; }
  });
});

await page.goto("https://example.com");
Enter fullscreen mode Exit fullscreen mode

evaluateOnNewDocument runs before any page script, so it intercepts lazy loading before it kicks in. This won't work for custom JS-based lazy loaders (which check scroll position or use IntersectionObserver), but it handles the native HTML attribute.

Dealing with IntersectionObserver loaders

Libraries like lazysizes, lozad, or custom implementations use IntersectionObserver. The scroll trick usually works, but here's a more reliable approach — force all observed elements to intersect:

await page.evaluate(() => {
  // Find all images with data-src (common lazy pattern)
  document.querySelectorAll("img[data-src]").forEach(img => {
    img.src = img.dataset.src;
    if (img.dataset.srcset) img.srcset = img.dataset.srcset;
  });

  // Same for background images
  document.querySelectorAll("[data-bg]").forEach(el => {
    el.style.backgroundImage = `url(${el.dataset.bg})`;
  });
});
Enter fullscreen mode Exit fullscreen mode

Brute force, but it works when you know the lazy loading pattern. The downside is it's brittle — every site structures their lazy loading differently.

Infinite scroll pages

Social feeds, product listings, search results — pages that load more content as you scroll. You usually don't want the entire feed. Cap it:

async function scrollWithLimit(page, maxScrolls = 10) {
  let previousHeight = 0;
  let scrollCount = 0;

  while (scrollCount < maxScrolls) {
    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
    await page.waitForTimeout(2000);

    const currentHeight = await page.evaluate(() => document.body.scrollHeight);
    if (currentHeight === previousHeight) break;

    previousHeight = currentHeight;
    scrollCount++;
  }

  window.scrollTo(0, 0);
}
Enter fullscreen mode Exit fullscreen mode

The 2-second wait is generous but safe. Some APIs take a while to respond, and if you scroll again before new content renders, you'll think you've hit the bottom when you haven't.

When scrolling isn't practical

Some pages are just hard to capture reliably. Heavy SPAs with virtual scrolling, pages that require authentication state, sites with aggressive bot detection that block headless browsers.

At some point the complexity of handling every edge case exceeds the value of doing it yourself. I've used ScreenshotRun for projects where I needed consistent captures without babysitting the rendering pipeline — it handles the lazy loading, wait conditions, and viewport stuff on their end.

Not always necessary, but worth knowing the option exists when your homegrown setup starts eating more time than it saves.

Quick checklist

Before you take a full-page screenshot:

  • Scroll the full page height with delays between steps
  • Wait 1-2 seconds after scrolling for images to finish loading
  • Override loading="lazy" if the site uses native lazy loading
  • Set a realistic viewport width (not the 800x600 default)
  • Use fullPage: true — otherwise you only capture the viewport area

Most screenshot issues come down to timing. The browser is ready before the content is. When in doubt, wait longer.

Top comments (0)