ScreenshotNeo

BlogHow-to

How to Show a Table That Loads on Scroll in a Puppeteer Screenshot

Scroll the table’s actual container, wait until the required rows exist, then capture the table or page with Puppeteer. Includes nested scrolling and virtualized-table guidance.

By the ScreenshotNeo team4 October 20269 min read

To include rows that load only after scrolling, make Puppeteer scroll the page or the table’s own scrollable container, wait until the rows you need have appeared, and only then take the screenshot. fullPage: true captures more page area; it does not itself trigger the table’s scroll-based loading.

Use ElementHandle.screenshot() for a table-only image or page.screenshot({ fullPage: true }) for the full page. The example below is a reusable pattern: replace the selectors, expected row count, and end condition with the target site’s actual DOM and data-loading behavior.

1. Identify what scrolls and what “loaded” means

Inspect the page and find the element whose scroll position changes when you scroll over the table. It may be the document, a table wrapper with overflow: auto, or another nested container. Scrolling the window will not trigger a handler attached to a nested container.

Choose an observable completion condition before writing the capture loop. Good options include:

  • A known last row or row identifier is present.
  • The rendered row count reaches a known total.
  • The table’s displayed total matches the expected number of records.
  • A loading indicator disappears after the target rows appear.

Waiting for a short delay can give the page time to render, but a delay alone cannot establish that all required data arrived. Network idleness can be useful as an additional signal; it is not proof that a particular row exists.

2. Scroll, wait, and capture with Puppeteer

This CommonJS script uses a nested scroll container and an expected row count. Install Puppeteer with npm install puppeteer, save this as capture-table.cjs, and run node capture-table.cjs. Set TARGET_URL to a page you are authorized to access and adapt the selectors and expected count.

const puppeteer = require('puppeteer');

const TARGET_URL = process.env.TARGET_URL || 'https://example.com/report';
const TABLE_SELECTOR = 'table';
const SCROLLER_SELECTOR = '.table-scroll-container';
const ROW_SELECTOR = `${TABLE_SELECTOR} tbody tr`;
const EXPECTED_ROWS = 250; // Set this from the page's known total.
const MAX_STEPS = 100;

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
    await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });

    const table = await page.waitForSelector(TABLE_SELECTOR, { timeout: 15000 });
    const scroller = await page.waitForSelector(SCROLLER_SELECTOR, { timeout: 15000 });

    let previousCount = -1;
    let stalledSteps = 0;
    for (let step = 0; step < MAX_STEPS; step++) {
      const count = await page.$$eval(ROW_SELECTOR, rows => rows.length);
      if (count >= EXPECTED_ROWS) break;

      await scroller.evaluate(el => {
        el.scrollTop += Math.max(200, Math.floor(el.clientHeight * 0.8));
      });

      try {
        await page.waitForFunction(
          (selector, oldCount) => document.querySelectorAll(selector).length > oldCount,
          { timeout: 5000 },
          ROW_SELECTOR,
          count,
        );
        stalledSteps = 0;
      } catch (error) {
        // A virtualized table can keep the same DOM row count as it replaces rows.
        // In that case, use a target-row or end-marker condition instead.
        const newCount = await page.$$eval(ROW_SELECTOR, rows => rows.length);
        if (newCount <= previousCount) stalledSteps++;
        else stalledSteps = 0;
        if (stalledSteps >= 3) {
          throw new Error(`Rows stopped increasing after ${step + 1} scrolls; revise the completion condition.`);
        }
      }
      previousCount = await page.$$eval(ROW_SELECTOR, rows => rows.length);
    }

    const finalCount = await page.$$eval(ROW_SELECTOR, rows => rows.length);
    if (finalCount < EXPECTED_ROWS) {
      throw new Error(`Expected ${EXPECTED_ROWS} rows, found ${finalCount} after ${MAX_STEPS} scroll steps.`);
    }

    await table.screenshot({ path: 'table.png' });
    // For a page-wide capture instead, use:
    // await page.screenshot({ path: 'page.png', fullPage: true });
  } finally {
    await browser.close();
  }
})().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

The loop has a maximum number of steps and fails clearly if the expected rows never appear. Its row-count condition suits tables that append rows to the DOM. If a table recycles a fixed set of row elements, use a predicate for the desired final row or another page-specific end marker instead.

3. Pick the right capture scope

Goal Capture Considerations
Only the table await table.screenshot({ path: 'table.png' }) The element is brought into view if needed. Confirm that the table’s rendered dimensions include the content you want.
The whole page await page.screenshot({ path: 'page.png', fullPage: true }) Captures the full page extent, but does not perform the scroll sequence needed to trigger lazy loading.
A very long table that is hard to read in one image Capture sections or use the site’s export/print view A single tall image can be unwieldy, and a virtualized table may not have every record in the DOM at once.

Puppeteer documents [Page.screenshot()](https://pptr.dev/api/puppeteer.page.screenshot), its [fullPage option](https://pptr.dev/api/puppeteer.screenshotoptions), and [ElementHandle.screenshot()](https://pptr.dev/api/puppeteer.elementhandle.screenshot). The element screenshot method scrolls the element into view if it is hidden, but that does not fetch data that the page has not loaded.

4. Adapt the loop to the table’s loading model

Page-level scrolling

If the document itself scrolls, advance the page instead of a table wrapper:

await page.evaluate(() => window.scrollBy(0, Math.max(200, window.innerHeight * 0.8)));

Then wait for your row, count, or end-marker condition. Puppeteer locators can also generate wheel scrolling for a selected element with locator.scroll(); see the [Locator API](https://pptr.dev/api/puppeteer.locator.scroll).

Wait for a specific row

When the last required row has a stable identifier, wait for it directly rather than assuming a total count:

await page.waitForFunction(
  (selector, expectedText) => {
    const rows = [...document.querySelectorAll(selector)];
    return rows.some(row => row.textContent.includes(expectedText));
  },
  { timeout: 10000 },
  'table tbody tr',
  'record-250',
);

Wait for a loading indicator to clear

If the application exposes a loading marker, combine its disappearance with a row condition. For example, wait until [aria-busy="true"] is absent or a known spinner is hidden, then verify the target row exists. A spinner disappearing alone may only mean one request finished.

Virtualized tables

Virtualized tables often keep only the visible rows and a small buffer in the DOM. As you scroll, old row nodes may be removed and reused, so the total DOM row count can stay constant. In this case:

  • Track a known row key or the first/last visible record as scrolling proceeds.
  • Stop when the target final record appears, rather than waiting for the DOM row count to reach the dataset total.
  • For a whole-dataset image, prefer an export or print view if available, or capture readable sections and combine them outside the page.
  • Do not assume one element screenshot includes records that are not currently rendered.

5. Other language clients and a managed screenshot API

Puppeteer’s browser automation API is JavaScript. If your application calls a screenshot service instead, these equivalent one-request examples save a screenshot response. The service must support the page’s scroll-loading behavior or provide an option to scroll before capture; a plain screenshot request cannot be assumed to load rows by itself.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
with open("shot.webp", "wb") as f:
    f.write(r.content)

Node.js

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 require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

See the [ScreenshotNeo API documentation](https://screenshotneo.com/docs/) for supported parameters and response details. ScreenshotNeo is a website screenshot API and MCP server from [Yorker Media](https://screenshotneo.com). Its options include full-page capture, element selection, wait conditions, custom JavaScript and CSS, viewport and device presets, and more. For a dynamically loaded table, configure scrolling or a wait condition appropriate to that site and verify the result before relying on it.

Or skip the browser setup

Use ScreenshotNeo’s one-call API for a screenshot request:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. These are API capture features; for scroll-loaded tables, set up the required scroll and completion behavior for the page.

Sign up for the free plan, and see the [API docs](https://screenshotneo.com/docs/) for parameters.

Troubleshooting

Symptom Likely cause Fix
The screenshot contains only the initial rows The capture ran before scrolling triggered loading, or before new rows rendered. Scroll the correct container and wait for a target row, changed count, or loading-state condition.
Scrolling has no effect The script scrolls the window while the table has its own overflow container, or vice versa. Inspect scrollable ancestors and update SCROLLER_SELECTOR; check that the container has remaining scroll range.
The loop times out even though the table loaded more records The table is virtualized and replaces rows instead of increasing the DOM count. Wait for a target record or changing row key/position rather than a larger row count.
waitForSelector times out The selector does not match, the table appears later, or the page did not reach the expected state. Check the selector in DevTools, account for frames if applicable, and wait for the page’s actual table-ready condition.
The capture ends before the final row The expected count is wrong, the maximum steps are too low, or loading stalled. Verify the page’s total, raise a bounded step limit if needed, and report failure if no progress occurs.
networkidle returns but rows are missing Network quiet does not assert that the application rendered the desired records. Wait for a content-specific condition; use network idleness only as a supplementary signal.
The resulting image is extremely tall or hard to read The table has many rows or the full-page capture includes unrelated content. Capture only the table or split it into sections; consider an export/print view.
Navigation times out on a page that still renders The site may keep requests open or load continuously. Use an appropriate navigation milestone such as domcontentloaded, then wait for the table’s own readiness condition.

Performance, reliability, and cost

  • Bound the work: Set both a maximum scroll count and a timeout. Stop on a known end condition and fail explicitly if it is not reached.
  • Avoid excessive polling: Wait for DOM state after each scroll instead of taking repeated screenshots or using long fixed sleeps.
  • Keep the browser lifecycle contained: Close the browser in a finally block so failures do not leave Chromium running.
  • Keep captures manageable: A tall image consumes memory and may be difficult to inspect; element captures or sections may be more practical.
  • Check the data, not only the image: Confirm the required record is in the rendered state before capture, especially after retries or on changing pages.
  • Cost depends on the approach: Running Puppeteer uses your own browser/runtime resources. ScreenshotNeo bills only clean shots; bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing. Its response includes X-Page-Verdict and X-Billed headers so you can see the page verdict and billing outcome.

FAQ

Does fullPage: true scroll the page to load every row?

No. It requests a full-page capture. Trigger the site’s loading behavior first, then verify that the rows are present.

Can ElementHandle.screenshot() load the table?

It scrolls the element into view if needed, but it does not guarantee that application data has been fetched. Perform the loading sequence and check the content first.

Why does the table show fewer DOM rows than the total?

The site may use virtualization and keep only visible rows in the DOM. Use an end-record condition or a different export/capture strategy.

Should I use a fixed delay or network idle?

Use a page-specific row or loading-state condition as the completion check. A delay or network-idle wait can supplement it, but neither says which records are present.