· Updated · Tutorials · By Simon Chiu

How to Screenshot Infinite Scroll Pages (Bounded)

Screenshot an infinite scroll page without hanging: stop after N items, at a max height or when the feed stops growing. Tested Playwright code and timings.

Line illustration of the RenderScreenshot mascot cutting an endless scrolling page with scissors

An infinite scroll page has no bottom, so "take a full-page screenshot" has no natural end. If you scroll until the page stops growing, your script hangs. If you do not scroll at all, you get the first batch of items. The fix is a bounded scroll: scroll in rounds, and stop at the first of four limits (item count, height, no growth, time). Then cut the image at a clean edge.

This post is part of our full-page screenshot guide. It covers the infinite-scroll case in depth, with code that I ran against a test feed I built for this article, and the timings from those runs.

Tested on 2026-10-15 with Node 22.18.0 and [email protected] (bundled Chromium), Python 3.14.8 with playwright 1.64.0, [email protected], on an Apple M1 Pro with macOS 27.0.1. All images on this page come from those runs.

The test page

Public feeds change every minute, so they make bad test targets. I wrote a small feed page instead: infinite-feed.html. It loads 10 cards per batch when a sentinel element under the list comes into view (an IntersectionObserver, the pattern most feeds use), with 300 ms of fake API latency. It has a sticky header. Query parameters change its behaviour:

Parameter Effect
total=N The feed ends after N items, like a search with few results
latency=MS Delay before each batch appears
virtual=1 Keeps only about 30 rows in the DOM, like a virtualized list

Save it next to the scripts and serve the folder with any static server, for example npx serve or python3 -m http.server.

What plain fullPage gives you

The first test: load the page and call page.screenshot({ fullPage: true }) with no scrolling.

A full-page capture of the infinite feed test page that contains only the first ten cards and ends after 1,553 pixels
Playwright 1.64.1, fullPage: true, no scrolling. 10 items, 1280 x 1553 px.

You get the first batch. The page loads more items only when the sentinel scrolls into view, and nothing scrolled. Counting items before and after the capture gave 10 both times, so the capture itself did not trigger more loads on this page. Do not count on that: some pages measure the viewport in a scroll or resize handler and load more while the browser renders the tall image. That is one more reason to cut the image at a height you choose, as the script below does.

The bounded-scroll script

The script scrolls with real wheel events, then waits up to 2 seconds for the item count or the document height to grow. It stops at the first of four limits, then takes one full-page capture with a clip so the image ends where you decided.

// capture-feed.mjs - bounded full-page capture of an infinite-scroll page.
// Node 22+, [email protected]. Usage:
//   node capture-feed.mjs <url> <out.png> [--items=50] [--max-height=12000] [--idle=3] [--budget=20000]
import { chromium } from 'playwright';

const [url, out = 'feed.png', ...flags] = process.argv.slice(2);
if (!url) {
  console.error('usage: node capture-feed.mjs <url> <out.png> [--items=N] [--max-height=PX] [--idle=N] [--budget=MS]');
  process.exit(2);
}
const opt = Object.fromEntries(flags.map((f) => f.replace(/^--/, '').split('=')));
const ITEM_SELECTOR = opt.selector ?? 'article.item';
const MAX_ITEMS = Number(opt.items ?? Infinity);
const MAX_HEIGHT = Number(opt['max-height'] ?? 12000);
const MAX_IDLE = Number(opt.idle ?? 3); // rounds with no growth before we call it the end
const BUDGET = Number(opt.budget ?? 20000); // total scroll time in ms
const WAIT_FOR_GROWTH = 2000; // ms to wait for new items after each scroll

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto(url, { waitUntil: 'load' });

  const count = () => page.locator(ITEM_SELECTOR).count();
  const height = () => page.evaluate(() => document.scrollingElement.scrollHeight);

  const started = Date.now();
  let idle = 0;
  let reason = 'budget';
  while (Date.now() - started < BUDGET) {
    const [n, h] = [await count(), await height()];
    if (n >= MAX_ITEMS) { reason = 'items'; break; }
    if (h >= MAX_HEIGHT) { reason = 'max-height'; break; }
    if (idle >= MAX_IDLE) { reason = 'no-growth'; break; }

    await page.mouse.wheel(0, h); // real wheel events, so scroll listeners fire
    try {
      await page.waitForFunction(
        ([sel, n0, h0]) =>
          document.querySelectorAll(sel).length > n0 ||
          document.scrollingElement.scrollHeight > h0,
        [ITEM_SELECTOR, n, h],
        { timeout: WAIT_FOR_GROWTH },
      );
      idle = 0;
    } catch (err) {
      if (err.name !== 'TimeoutError') throw err;
      idle += 1;
    }
  }

  // Decide where the image ends: after the Nth item, or at the height cap.
  const items = await count();
  let bottom = Math.min(await height(), MAX_HEIGHT);
  if (Number.isFinite(MAX_ITEMS) && items >= MAX_ITEMS) {
    const box = await page.locator(ITEM_SELECTOR).nth(MAX_ITEMS - 1).boundingBox();
    const scrollY = await page.evaluate(() => scrollY);
    bottom = Math.ceil(box.y + scrollY + box.height + 12);
  }

  await page.evaluate(() => scrollTo(0, 0));
  await page.screenshot({
    path: out,
    fullPage: true,
    clip: { x: 0, y: 0, width: 1280, height: bottom },
  });
  console.log(JSON.stringify({ out, reason, items, bottom, ms: Date.now() - started }));
} finally {
  await browser.close();
}

Three details matter more than they look:

  • Wait for growth, not for time. waitForFunction returns as soon as the count or the height changes. A fixed sleep after each scroll is either too long (slow captures) or too short (missed batches on a slow API).
  • Check two signals. Some feeds append items without changing the height yet (images still loading). Others grow the height with skeleton rows before the items exist. Either change counts as progress.
  • Rethrow everything except timeouts. A timeout means "no growth this round". Any other error, such as a closed page, must fail the run with a non-zero exit code.

The full file is also at capture-feed.mjs.

Choosing a stop rule

I ran each configuration three times against the local test page. The time is the scroll loop only (not browser start or encoding), so it shows the cost of each rule.

Stop rule Flags Why it stopped Items in DOM Image height Bytes (PNG) Scroll time, 3 runs
No scrolling (plain fullPage) none n/a 10 1,553 px n/a n/a
Item count --items=30 items 30 4,267 px 182,929 1.16 / 1.40 / 1.14 s
Item count, larger --items=100 --max-height=20000 items 100 14,067 px n/a 3.98 / 4.04 / 3.76 s
Height cap --max-height=6000 max-height 50 6,000 px 258,845 1.87 / 1.87 / 1.86 s
Feed ends at 25, idle 3 ?total=25 no-growth 25 3,653 px 154,495 7.14 / 7.14 / 7.16 s
Feed ends at 25, idle 1 ?total=25 --idle=1 no-growth 25 3,653 px n/a 3.23 / 3.11 / 3.14 s
Time budget --budget=3000 budget 90 12,000 px (default cap) 525,930 3.60 / 3.40 / 3.45 s
Five captures of the same feed at the same scale: the plain capture is short, the item-count and end-of-feed captures are medium, the height-capped one is taller, and the virtualized list capture is tall but mostly empty
The same feed under five rules, drawn at 6% scale. The last column is the virtualized list, explained below.

What the numbers say:

  • The item count is the best rule when the page has a repeated item. It is fast, it is the number your user cares about ("the first 30 results"), and it cuts the image after a whole card.
  • The no-growth rule costs idle x wait at the end. With 3 idle rounds of 2 seconds, detecting the true end of a 25-item feed took about 7 seconds; with 1 idle round, about 3 seconds. One idle round is too few for a slow API, so tune the wait time to the page, not the round count.
  • A height cap cuts through content. At 6,000 px the image ends in the middle of item 43. It is a good safety limit and a poor primary rule.
Bottom of the 30-item capture: item 30 is complete and the image ends just below it
--items=30: the image ends 12 px under item 30.
Bottom of the 6,000 pixel capture: the last card, item 43, is cut in half
--max-height=6000: the cut goes through item 43.
Bottom of the capture of a feed that ends after 25 items, showing the end-of-feed message
?total=25: the no-growth rule found the real end and the "End of feed" message.

Always set all four limits, even if you rely on one. The time budget and the height cap protect you from pages that misbehave: a feed that loads one item per second forever, or a page that grows by a few pixels on every scroll because of an animation.

The same rule in Python

The Python API has the same names in snake case. This version stops at an item count only and keeps the no-growth guard.

# capture_feed.py - stop after N items. Python 3.10+, playwright==1.64.0
import sys
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout

URL, OUT, MAX_ITEMS = sys.argv[1], sys.argv[2], int(sys.argv[3])
ITEM = "article.item"

with sync_playwright() as p:
    browser = p.chromium.launch()
    try:
        page = browser.new_page(viewport={"width": 1280, "height": 800})
        page.goto(URL)
        items = page.locator(ITEM)
        idle = 0
        while items.count() < MAX_ITEMS and idle < 3:
            n = items.count()
            page.mouse.wheel(0, 10_000)
            try:
                page.wait_for_function(
                    "([sel, n]) => document.querySelectorAll(sel).length > n",
                    arg=[ITEM, n],
                    timeout=2000,
                )
                idle = 0
            except PWTimeout:
                idle += 1

        last = items.nth(min(items.count(), MAX_ITEMS) - 1)
        bottom = last.evaluate("el => el.getBoundingClientRect().bottom + scrollY")
        page.evaluate("scrollTo(0, 0)")
        page.screenshot(
            path=OUT,
            full_page=True,
            clip={"x": 0, "y": 0, "width": 1280, "height": int(bottom) + 12},
        )
        print(f"{OUT}: {items.count()} items in DOM, cut at {int(bottom) + 12}px")
    finally:
        browser.close()

Run with python capture_feed.py http://localhost:3000/infinite-feed.html feed.png 40. On the test page it printed 40 items in DOM, cut at 5666px.

Virtualized lists: the blank-bottom problem

Large feeds and data grids often use virtualization (react-window, TanStack Virtual and similar). The list keeps only the rows near the viewport in the DOM and fills the rest with padding, so the scroll height is right but most of the document is empty. A full-page capture draws that empty padding.

With ?virtual=1, the item-count rule never fires (the DOM never holds more than about 30 rows), so the script ran to the 12,000 px cap. After scrollTo(0, 0) the list rendered rows 1 to 30, and everything under them was blank:

Full-page capture of the virtualized feed: items 27 to 30 are drawn and the area under them is empty grey background
Virtualized list, one fullPage capture: rows exist only near the scroll position.

The fix is to capture what is on screen at each scroll position and stitch the tiles. Hide the sticky header after the first tile, or it repeats in every tile.

// stitch-virtual.mjs - capture a virtualized list by scrolling and stitching viewport tiles.
// Node 22+, [email protected], [email protected]. Usage: node stitch-virtual.mjs <url> <out.png> <maxHeight>
import { chromium } from 'playwright';
import sharp from 'sharp';

const [url, out = 'stitched.png', maxHeight = '6000'] = process.argv.slice(2);
const W = 1280, VH = 800;

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: W, height: VH } });
  await page.goto(url);

  // 1. Load enough rows: scroll until the document is tall enough (or stops growing).
  for (let i = 0; i < 50; i++) {
    const h = await page.evaluate(() => document.scrollingElement.scrollHeight);
    if (h >= Number(maxHeight)) break;
    await page.mouse.wheel(0, h);
    await page.waitForFunction((h0) => document.scrollingElement.scrollHeight > h0, h, { timeout: 2000 })
      .catch((err) => { if (err.name !== 'TimeoutError') throw err; });
  }
  const total = Math.min(Number(maxHeight), await page.evaluate(() => document.scrollingElement.scrollHeight));

  // 2. Walk down one viewport at a time and grab what is rendered there.
  const tiles = [];
  for (let y = 0; y < total; y += VH) {
    await page.evaluate((y) => scrollTo(0, y), y);
    // Two animation frames: the scroll handler re-renders, then the browser paints.
    await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
    const top = await page.evaluate(() => scrollY); // the last tile may not reach y
    tiles.push({ input: await page.screenshot(), top });
    if (y === 0) await page.addStyleTag({ content: 'header { visibility: hidden !important; }' });
  }

  // 3. Stitch. Later tiles overwrite the overlap at the bottom, which is fine: same pixels.
  await sharp({ create: { width: W, height: total, channels: 3, background: '#ffffff' } })
    .composite(tiles.map(({ input, top }) => ({ input, top, left: 0 })))
    .png()
    .toFile(out);
  console.log(JSON.stringify({ out, tiles: tiles.length, height: total }));
} finally {
  await browser.close();
}

Three runs each produced 8 tiles and a 6,000 px image with every row drawn and no visible seams:

The same region of the virtualized feed after stitching: items 27 to 35 are all drawn with even spacing
Same region after scroll-and-stitch: items 27 to 35 are present.

Stitching has costs. Anything with position: fixed (chat buttons, cookie bars) shows up in every tile unless you hide it, and animations that run during the walk can leave visible seams. Use it only when the page is virtualized; for normal feeds, the single capture above is simpler and exact. Our post on sticky headers in full-page captures covers the fixed-element problem in more detail.

The same idea with the RenderScreenshot API

Disclosure: RenderScreenshot is my product. The API has the height cap and the scroll pass built in, as documented in the capture parameters: capture.scroll.simulate scrolls the page step by step before the capture, scroll.delay sets the wait between steps, scroll.step sets the distance, and scroll.max_height (default 16,000 px) caps the image. These options are POST-only.

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/feed",
    "capture": {
      "mode": "full_page",
      "scroll": { "simulate": true, "delay": 600, "max_height": 6000 }
    },
    "output": { "format": "webp", "quality": 80 }
  }' \
  -o feed.webp

What the API does not have: an item-count stop, a no-growth detector you can tune, or tile stitching for virtualized lists. If you need "exactly the first 30 results", the DIY script above is the right tool, or you set max_height to the height of 30 items when the item height is fixed. If the long image is going into a vision model, split it afterwards; the guide to splitting long screenshots has tested sharp and Pillow code. A free API key is enough to compare the API output with your local runs.

Troubleshooting

Symptom Cause Fix
Only the first batch is in the image No scroll before capture Scroll in rounds until a stop rule fires
Script never ends Only a "page stopped growing" rule, on a page that never stops Add a time budget and a height cap
New items never load The loader may listen for wheel or touch events that a scripted scrollTo does not send Use page.mouse.wheel(), or scroll the inner container if the feed scrolls inside a div
Feed scrolls inside a container, page height stays 800 px The scroll element is a div with overflow: auto Measure and scroll that element, then capture it with an element screenshot
Bottom half of the image is blank Virtualized list Scroll and stitch tiles, or capture the visible window only
Last card is cut in half Height cap was the stop reason Stop on item count and clip after the Nth item
Images in later cards are grey boxes Lazy images had no time to load Wait for img.complete in the last batch before the capture, see lazy-loaded images
End detection takes many seconds idle rounds x wait time Lower the wait time to what the page's API needs
Image larger than expected in MB Tall PNG Use WebP or JPEG for archives; PNG at 12,000 px was 526 KB here, but photo-heavy feeds are far larger

For the general full-page cases (fixed heights, lazy images, the Chrome height limit), go back to the Puppeteer full-page screenshot guide.

Related in this topic