Handling Pagination and Infinite Scroll When Scraping

Matt George

Fractional CTO · Founder of Injuria.ai

Web scrapers that need to extract more than a single page of results inevitably encounter pagination. Modern web applications present data in chunks, using either traditional numbered page links or dynamic 'infinite scroll' interfaces. Successfully navigating these mechanisms is a fundamental skill for reliable data extraction at any scale. While they appear different to a user, the underlying technical approaches to scraping them fall into two main categories: direct API interaction and browser automation.

Understanding the mechanics of how a target site delivers paginated data is the key to choosing the right extraction strategy. The most efficient and stable method is often to bypass the user interface entirely and communicate directly with the site's backend API, just as the frontend JavaScript does. When that's not feasible, controlling a headless browser provides a powerful but more resource-intensive alternative. This article details both approaches, their trade-offs, and the decision framework for choosing between them.

Classic Pagination: The Predictable Loop

The oldest and simplest form of pagination involves the server rendering a complete HTML document for each page of results. Users navigate by clicking links, typically labeled with page numbers or 'Next' and 'Previous'. From a scraper's perspective, this is the most straightforward scenario.

How It Works

When you click a link like example.com/products?page=2, your browser sends a standard HTTP GET request[1] to the server. The server's backend code receives this request, notes the page=2 query parameter, and constructs a database query to fetch the appropriate slice of data. A typical SQL query for this might use LIMIT and OFFSET clauses.

For example, to get 20 items for page 2, the query might look like this:

SELECT * FROM products
ORDER BY creation_date DESC
LIMIT 20 OFFSET 20;

The OFFSET 20 command tells the database to skip the first 20 records (which appeared on page 1), and LIMIT 20 retrieves the next 20. The server then embeds these results into an HTML template and sends the full page back to the browser. This pattern is stateless and relies on simple, repeatable requests.

Scraping Strategy

Scraping classic pagination is a process of identifying the URL structure and iterating through it. The page number is usually in a query parameter (?page=N) or part of the URL path (/page/N).

The strategy is to loop through page numbers, making a request for each one, until you detect the end of the results. The end can be signified by an HTTP 404 Not Found status code[2], a page that contains a 'No results found' message, or a page that simply has no items to parse.

Here is a conceptual example in Python using requests for HTTP calls and BeautifulSoup for HTML parsing:

import requests
from bs4 import BeautifulSoup

BASE_URL = "http://example.com/products"

# It's good practice to identify your scraper with a User-Agent header.
headers = {
 'User-Agent': 'MyAwesomeScraper/1.0 (my-contact-info@example.com)'
}

page = 1
while True:
 # Construct the URL for the current page
 url = f"{BASE_URL}?page={page}"
 print(f"Scraping page {page}...")

 try:
 response = requests.get(url, headers=headers, timeout=10)
 response.raise_for_status() # Raises an HTTPError for bad responses (4xx or 5xx)
 except requests.exceptions.RequestException as e:
 print(f"Request failed: {e}")
 break

 soup = BeautifulSoup(response.text, 'html.parser')
 items = soup.select('.product-item') # Assumes products have this CSS class

 if not items:
 # Stop if the page has no items
 print("No more items found. Stopping.")
 break

 for item in items:
 # Process each item...
 product_name = item.select_one('.product-name').text
 print(f" Found: {product_name}")

 page += 1

Trade-offs and Failure Modes

This method is simple and reliable. However, it can be inefficient for websites. Deep pagination using OFFSET can cause poor database performance, as the database still has to scan through all the records specified in the offset before it can return the result set. Some systems[3] experience significant slowdowns on very high page numbers. For the scraper, this is not a direct problem, but it's a reason why many modern sites have moved away from this model.

The Rise of Asynchronous Loading and Infinite Scroll

Many modern websites, especially single-page applications (SPAs)[4], have abandoned the full-page-reload model. Instead, they load an initial shell of a page and then fetch data dynamically using JavaScript.

History and Context

This shift was enabled by the popularization of AJAX[5], a term coined in a 2005 article by Jesse James Garrett. AJAX isn't a single technology but a collection of techniques that allow a web page to communicate with a server asynchronously, without interfering with the current state of the page. Originally using the XMLHttpRequest object and XML, modern implementations predominantly use the fetch() API[6] and transfer data using JSON[7].

This capability led to user interface patterns like infinite scroll and 'Load More' buttons. They provide a more fluid user experience by fetching and displaying new content on the same page, eliminating the jarring effect of a full page reload.

How It Works

The mechanism is consistent across infinite scroll and 'Load More' buttons:

  1. Initial Load: The browser loads the initial HTML page, which includes the first set of items and the necessary JavaScript code.
  2. Trigger: An event is triggered. For infinite scroll, a JavaScript event listener fires when the user scrolls near the bottom of the visible content. For a 'Load More' button, the trigger is a click event.
  3. API Request: The JavaScript executes a function that makes an asynchronous HTTP request to a specific backend API endpoint. This request includes parameters to ask for the next chunk of data, such as a page number or a cursor.
  4. API Response: The server processes the request and sends back only the data for the new items, typically in JSON format. It does not render any HTML.
  5. DOM Manipulation: The browser's JavaScript receives the JSON response, parses it, and dynamically creates new HTML elements (like <div>s or <li>s) for each new item. These new elements are then inserted into the page's Document Object Model (DOM)[8], becoming visible to the user.

To the user, it looks like the page is magically extending. To a scraper that only reads the initial HTML source, the new content is invisible because it's loaded and rendered after the initial page load.

Strategy 1: Reverse-Engineering the API

Since the frontend JavaScript is just a client for a backend API, your scraper can be one too. This is the most efficient and robust way to handle dynamic content.

The Process

The goal is to find the API endpoint that the page calls and replicate those calls in your scraper. You can do this using the network inspection tools built into modern browsers.

  1. Open your browser's Developer Tools (usually by pressing F12 or Ctrl+Shift+I).
  2. Go to the 'Network' tab.
  3. Filter the requests by 'Fetch/XHR'. This will show you only the asynchronous API requests made by the page's JavaScript.
  4. Navigate to the target website and start scrolling or clicking the 'Load More' button.
  5. You will see new requests appear in the Network tab. Click on one to inspect it.
  6. Look at the 'Headers' tab to find the Request URL. This is the API endpoint. You should also check the request method (e.g., GET or POST) and any custom request headers.
  7. Look at the 'Payload' or 'Params' tab to see what data was sent with the request. This will reveal the pagination parameters.
  8. Look at the 'Response' or 'Preview' tab to see the data returned by the server. It's often a structured JSON object containing a list of results and sometimes metadata about the next page.

Once you've identified the URL pattern, headers, and parameters, you can write a scraper that hits this API directly, completely bypassing the HTML frontend.

Cursor vs. Offset Pagination

When inspecting API calls, you'll often find one of two pagination schemes:

  • Offset-based: The API uses parameters like page=2&limit=20 or offset=20&limit=20. This is simple but suffers from the same potential performance issues as its server-rendered counterpart.
  • Cursor-based: The API uses an opaque identifier, or cursor, that points to a specific item in the dataset. The request might look like ?limit=20&after=aXb23d.... The response for this request will contain the next set of items and a new cursor for the subsequent page. This is far more efficient for the database, as it can seek directly to the cursor's location instead of counting an offset. Many large-scale APIs, like those from Slack[9] and Twitter[10], use cursor pagination.

Concrete Example: Scraping a JSON API

Imagine our inspection reveals that a 'Load More' button triggers a GET request to https://api.example.com/data?page=N. The response is JSON.

{
 "items": [
 {"id": 101, "name": "Item A"},
 {"id": 102, "name": "Item B"}
 ],
 "has_more": true,
 "next_page": 2
}

Your Python scraper can be simplified to just an API client.

import requests

API_ENDPOINT = "https://api.example.com/data"
headers = {
 'User-Agent': 'MyAwesomeScraper/1.0 (my-contact-info@example.com)',
 'Accept': 'application/json' # Good practice to specify expected content type
}

page = 1
while True:
 print(f"Fetching page {page} from API...")
 params = {'page': page}
 
 try:
 response = requests.get(API_ENDPOINT, headers=headers, params=params, timeout=10)
 response.raise_for_status()
 data = response.json()
 except requests.exceptions.RequestException as e:
 print(f"API request failed: {e}")
 break
 except requests.exceptions.JSONDecodeError:
 print("Failed to decode JSON response.")
 break

 for item in data.get('items', []):
 print(f" Found item: {item['name']} (ID: {item['id']})")

 # Stop if the API indicates there are no more pages
 if not data.get('has_more'):
 print("API indicates no more pages. Stopping.")
 break

 page += 1 # Or use data.get('next_page') if provided

This approach is clean, fast, and consumes minimal resources.

Strategy 2: Controlling a Headless Browser

Sometimes, reverse-engineering the API is not practical. The API calls might be intentionally obfuscated, or they might require complex authentication tokens or signatures that are generated by a large, minified JavaScript codebase and are difficult to replicate. In these cases, the fallback is to automate a real web browser.

A headless browser is a web browser that runs without a graphical user interface. You can control it programmatically to perform actions a user would, such as clicking, typing, and scrolling.

The Process

Modern libraries like Playwright[11] and Puppeteer[12] (created by Microsoft and the Google Chrome team, respectively) provide high-level APIs for browser automation. The legacy tool in this space is Selenium[13], which is still widely used.

The scraping process with a headless browser looks like this:

  1. Launch: Start a headless browser instance.
  2. Navigate: Instruct the browser to go to the target URL.
  3. Interact: Programmatically execute JavaScript to scroll the page down. A common snippet is window.scrollTo(0, document.body.scrollHeight).
  4. Wait: This is the most critical and difficult step. After scrolling, you must wait for the JavaScript to make its API call and render the new content. A naive sleep() is unreliable. Better strategies involve waiting for a specific network request to complete or waiting for the number of item elements on the page to increase.
  5. Loop: Repeat the scroll-and-wait process until no new content is loaded. You can detect this by checking if the page height stops increasing after a scroll or if a 'no more results' element appears.
  6. Extract: Once all the content is loaded into the DOM, you can get the entire page's HTML and parse it with a library like BeautifulSoup, just as you would with a static page.

Concrete Example: Scrolling with Playwright

This Python example uses Playwright to repeatedly scroll to the bottom of a page until no new items appear for a few seconds.

import asyncio
from playwright.async_api import async_playwright

async def main():
 async with async_playwright() as p:
 browser = await p.chromium.launch(headless=True)
 page = await browser.new_page()
 await page.goto("http://example.com/infinite-scroll-page")

 # Get the initial number of items
 last_height = await page.evaluate("document.body.scrollHeight")

 while True:
 # Scroll to the bottom of the page
 await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
 
 # Wait for new content to load. A more robust way is to wait for a specific
 # network response or DOM change, but a timeout is a simpler starting point.
 try:
 await page.wait_for_function(
 f"document.body.scrollHeight > {last_height}",
 timeout=5000 # Wait up to 5 seconds for the height to change
 )
 except Exception: # Playwright's TimeoutError
 print("No new content loaded for 5 seconds. Assuming end of page.")
 break

 last_height = await page.evaluate("document.body.scrollHeight")
 print(f"Scrolled. New page height: {last_height}")

 # Now that all content is loaded, extract it
 content = await page.content()
 # You can now parse 'content' with BeautifulSoup or another parser
 print("Finished scrolling. Page content is ready for parsing.")
 
 await browser.close()

if __name__ == '__main__':
 asyncio.run(main())

Decision Lens: API vs. Headless Browser

A technical leader must weigh the costs and benefits of each approach. The choice has significant implications for development time, infrastructure cost, and long-term maintenance.

Use API Reverse-Engineering When:

  • Performance and cost are critical. API calls are orders of magnitude faster and cheaper than running a full browser. A single server can make thousands of concurrent API requests, while running thousands of browser instances is a massive infrastructure challenge.
  • You need structured data. APIs provide clean, structured JSON, eliminating the need for brittle HTML parsing.
  • Long-term stability is desired. APIs, especially public or partner APIs, tend to be more stable than a website's HTML structure and CSS class names, which are subject to frequent redesigns.

The primary drawback is the upfront investigation cost. It requires a developer with a good understanding of HTTP and browser developer tools. The API may also be undocumented or use anti-scraping measures like token-based authentication that are hard to replicate.

Use a Headless Browser When:

  • The API is too complex or obfuscated. If you cannot decipher the API calls after a reasonable amount of effort, a headless browser is your only option.
  • The site relies heavily on complex JavaScript rendering. For some applications, the process of generating content is so complex that simulating it is easier than deconstructing it.
  • A quick prototype is needed. It can sometimes be faster to write a browser automation script to get an initial result, even if it's not the most efficient long-term solution.

The primary drawbacks are performance and fragility. Headless browsers are resource-heavy, consuming significant CPU and memory. They are also slow, as they must wait for the page to render, execute JavaScript, and load resources. Scripts based on browser automation are often brittle and can break with minor changes to the website's layout or design.

Common Misconceptions and Advanced Challenges

Misconception: "Infinite scroll requires a headless browser."
This is the most common fallacy. As shown, infinite scroll is almost always powered by a discoverable API. The browser automation approach should be a fallback, not the default.

Misconception: "I can just scrape the JavaScript code to find the API endpoint."
While possible, modern web development often involves bundling and minifying JavaScript into large, unreadable files. Finding the relevant code that makes the API call can be much harder than simply observing the call in the Network tab.

Advanced Hurdles

  • Rate Limiting: Whether you're hitting an API or loading web pages, servers will block IPs that make too many requests in a short period. Your scraper must handle this gracefully, for example by implementing exponential backoff, slowing down its request rate, or using a pool of rotating proxy servers.
  • Authentication: Many APIs require an authentication token (e.g., a JWT[14]). Your scraper may need to perform a login flow (either via API calls or a headless browser) to obtain a valid session cookie or token, which it then includes in subsequent requests.
  • Anti-Scraping Services: Companies like Cloudflare[15] and Akamai provide sophisticated bot detection. They may present JavaScript challenges, analyze mouse movements, or use browser fingerprinting to distinguish automated clients from human users. Overcoming these defenses is a complex, specialized field and often requires advanced browser automation techniques that more closely mimic human behavior.

Where Things Stand Today

The web scraping landscape continues to evolve alongside web development practices. The trend towards API-first design and SPAs has, in many ways, made scraping more straightforward for those who know how to find and use the underlying APIs. For browser automation, tools have matured significantly. Obsolete projects like PhantomJS[16] have been replaced by powerful, well-supported libraries like Playwright and Puppeteer, which are backed by major browser vendors.

When building a data extraction system, the most sustainable strategy is to favor the API-first approach for its efficiency and stability. Reserve the use of resource-intensive headless browsers for the tough cases where direct API access is intractable. Finally, always build scrapers to be good citizens of the web: make requests at a reasonable rate, identify your bot with a User-Agent[17] header, and respect the site's terms of service and the conventions of robots.txt[18]. While legal precedent around web scraping continues to be debated, such as in cases related to the Computer Fraud and Abuse Act[19], responsible and considerate technical design remains the best practice.

Sources (19)
  1. MDN Web Docs: HTTP GET
  2. IETF RFC 7231: 404 Not Found
  3. Use The Index, Luke!: Paging Through Results
  4. Wikipedia: Single-page application
  5. MDN Web Docs: AJAX
  6. MDN Web Docs: Fetch API
  7. JSON.org
  8. MDN Web Docs: Document Object Model (DOM)
  9. Slack API: Pagination
  10. Twitter API: Pagination
  11. Playwright
  12. Puppeteer
  13. Selenium
  14. JWT.io: Introduction to JSON Web Tokens
  15. Cloudflare: What is Bot Management?
  16. GitHub: PhantomJS (Archived)
  17. MDN Web Docs: User-Agent
  18. The Robots.txt Protocol
  19. Wikipedia: Computer Fraud and Abuse Act

Recommended reading