Infinite Scroll: What It Is and How to Capture It

Infinite scroll loads more items as you near the bottom, so a page has no fixed end. Measured page growth per scroll step, and capped capture code.

By Simon Chiu · Updated

Infinite scroll is a page pattern that loads and appends the next batch of content when the visitor scrolls near the bottom, so the page has no fixed end and no page numbers.

Social feeds, search results, image galleries and product listings use it. This entry is part of the screenshot and rendering glossary. It is related to lazy loading, but the difference matters for captures: lazy loading fills in content that is already in the page, while infinite scroll adds new content and makes the page taller.

A plain explanation

A paginated list has a "next page" link. An infinite list replaces the link with a trigger near the bottom. When the trigger comes into view, a script fetches the next batch and appends it. The new items push the trigger down again, and the cycle repeats as long as the visitor scrolls and the server has items.

For a person this feels smooth. For a capture it means two things. The page is only as long as the scrolling that happened before the capture. And if the feed never ends, "capture the whole page" has no answer. You have to decide how much to capture.

How the page grows

We built a feed that appends 10 posts (172 px each) whenever a sentinel element at the bottom comes within 400 px of the viewport. The server never runs out of posts. Then we measured the page with Puppeteer at an 800 x 600 viewport.

After load3,453 px, 20 items After full-page capture3,453 px, 20 items After 5 scroll steps5,173 px, 30 items After 10 steps8,613 px, 50 items After 15 steps12,053 px, 70 items One step = one viewport (600 px), 400 ms pause. Height cap reached at step 15.
Page height of the test feed, measured with Puppeteer 25.14.0 on 2026-10-15. The bars are drawn to scale.

Three observations:

  1. The first load already fetched twice. The sentinel starts near the top of an empty feed, so it fired once before the first batch arrived and once more after. The page opened with 20 items, not 10.
  2. A full-page screenshot changed nothing. It drew 3,453 px and the page stayed at 20 items, because the capture does not scroll.
  3. Growth was steady and had no end. About 10 items every 2 to 3 steps. Our loop stopped only because of the 12,000 px limit we set.

How it works technically

Most current feeds use an IntersectionObserver on a sentinel element below the last item, with a rootMargin so the fetch starts before the visitor reaches the end. Older feeds listen to the scroll event and compare scrollY + innerHeight with the page height. Some use a "load more" button after a few batches, and some remove items from the top as the list grows (list virtualization), so a tall capture can still miss the first posts.

For automation, three facts follow. The trigger only fires when the viewport moves, so you must scroll. Each batch is a network request, so you must pause between steps. And you need your own stop rule: a height limit, a step limit, or "the height did not grow and we are at the bottom".

How to capture it in Puppeteer, Playwright and RenderScreenshot

Puppeteer. Scroll one viewport at a time, stop on a limit or at the real end, then capture a fixed height.

import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const MAX_STEPS = 15;       // a feed never ends, so cap the work
const MAX_HEIGHT = 12_000;  // and the image height
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

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

  let height = await page.evaluate(() => document.documentElement.scrollHeight);
  for (let step = 0; step < MAX_STEPS && height < MAX_HEIGHT; step++) {
    await page.evaluate(() => window.scrollBy(0, window.innerHeight));
    await sleep(400); // time for the fetch and the new items
    const next = await page.evaluate(() => document.documentElement.scrollHeight);
    // No growth and already at the bottom: the feed has stopped loading.
    const atBottom = await page.evaluate(() => window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2);
    if (next === height && atBottom) break;
    height = next;
  }
  await page.evaluate(() => window.scrollTo(0, 0));
  await page.screenshot({
    path: 'feed.png',
    clip: { x: 0, y: 0, width: 1280, height: Math.min(height, MAX_HEIGHT) },
    captureBeyondViewport: true,
  });
  console.log('captured height', Math.min(height, MAX_HEIGHT));
} finally {
  await browser.close();
}

Playwright. The mouse wheel sends real wheel events, which some feeds listen for.

import { chromium } from 'playwright';

const url = process.argv[2] ?? 'https://example.com';
const MAX_STEPS = 15;
const MAX_HEIGHT = 12_000;

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto(url, { waitUntil: 'networkidle' });
  let height = await page.evaluate(() => document.documentElement.scrollHeight);
  for (let step = 0; step < MAX_STEPS && height < MAX_HEIGHT; step++) {
    await page.mouse.wheel(0, 800); // real wheel events, which some feeds listen for
    await page.waitForTimeout(400);
    const { next, atBottom } = await page.evaluate(() => ({
      next: document.documentElement.scrollHeight,
      atBottom: window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2,
    }));
    if (next === height && atBottom) break;
    height = next;
  }
  await page.evaluate(() => window.scrollTo(0, 0));
  // Playwright has no captureBeyondViewport switch; a clip larger than the viewport works with fullPage.
  await page.screenshot({ path: 'feed-pw.png', fullPage: true, clip: { x: 0, y: 0, width: 1280, height: Math.min(height, MAX_HEIGHT) } });
  console.log('page height', height);
} finally {
  await browser.close();
}

RenderScreenshot. Turn on capture.scroll.simulate for a full-page capture, and set scroll.max_height as the stop rule (default 16,000 px). scroll.step and scroll.delay control the distance and the pause. See capture options.

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, "step": 800, "delay": 500, "max_height": 12000 }
    }
  }' \
  --output feed.png

Tested on 2026-10-15 with Node 22.18.0, Puppeteer 25.14.0 (Chrome 155) and Playwright 1.64.1 (Chromium 156), against https://example.com and the local test feed. Both scripts stopped at the 12,000 px cap on the feed and after one step on example.com. The RenderScreenshot request follows the API docs and was not run for this page.

Common mistakes

  • No stop rule. A loop that scrolls "until the end" runs until the timeout on a feed that never ends.
  • One jump to the bottom. It skips the middle and triggers only one batch per jump.
  • No pause. The fetch needs time. Without a pause, the height check sees no growth and stops early.
  • Huge images. A 50,000 px PNG is slow to encode and hard to view. Cap the height, or split it into several images.

The deep guide, with bounded capture rules and virtualized lists, is screenshotting infinite scroll pages. To capture feeds with a height cap built in, get a free API key.

Frequently asked questions

Why does my full-page screenshot of a feed show only the first items?
The capture does not scroll, so the feed never asks for more items. You have to scroll first, wait for each batch, and then capture.
How do I know when an infinite scroll page has ended?
Often you do not, because some feeds never end. Stop when the page height stops growing at the bottom, or when you reach a step or height limit that you set.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.