Skip to content

Chapter 7: Infinite Scrolling & Pagination

Theoretical Foundations

In the landscape of modern web development, managing the presentation of large datasets is a fundamental challenge that directly impacts user experience, performance, and system scalability. When an interface needs to display hundreds, thousands, or even millions of items—be it social media posts, product listings, or chat messages—fetching and rendering them all at once is computationally disastrous. It leads to massive initial payload sizes, sluggish client-side rendering, and a paralyzed user interface. To solve this, we employ architectural patterns that break the data into manageable segments. The two dominant paradigms are Traditional Pagination and Infinite Scrolling. Understanding their theoretical underpinnings is crucial before implementing them with modern tools like tRPC and Edge Functions.

The Dichotomy of Segmentation: Pages vs. Streams

Traditional Pagination is the classic, structured approach, analogous to reading a physical book. You see a fixed number of items (e.g., 20) on a "page," and you explicitly navigate to the next or previous page via numbered links or "Next/Previous" buttons. Each page is a discrete, self-contained unit of data. The server's role is simple: given a page number and a page size, it calculates an offset ((page - 1) * pageSize) and returns that specific slice of the dataset.

The primary advantage of this model is predictability and statelessness. The client knows exactly what data it has and what it needs. However, it suffers from a rigid user experience. The act of clicking "Next" causes a full context switch, however brief, breaking the user's flow. It also struggles with dynamic data; if a new item is added to the dataset while you are on page 3, the items on subsequent pages shift, potentially causing duplicates or missed items if not handled carefully (e.g., via timestamp-based cursors instead of offsets).

Infinite Scrolling, in contrast, mimics a flowing river or an endless tape. As the user scrolls towards the bottom of the viewport, new data is seamlessly appended, creating an illusion of an infinitely long page. This pattern prioritizes immersion and discovery, making it ideal for content feeds like Twitter, Instagram, or Pinterest. The user is not burdened with navigation decisions; the interface anticipates their intent to consume more content.

However, this fluidity comes at a cost. Without careful management, infinite scrolling can lead to:

  1. Performance Degradation: The DOM becomes bloated with thousands of rendered elements, consuming excessive memory and slowing down the browser.
  2. Loss of Context: It's difficult to return to a specific point in the feed without losing your place.
  3. Unbounded Resource Consumption: The client may continuously fetch data, even if the user is idle, leading to unnecessary network traffic and server load.

The key to implementing infinite scrolling effectively is to treat it not as a single monolithic fetch, but as a series of discrete, sequential data fetches triggered by user interaction (scrolling). This is where Cursor-Based Pagination becomes essential.

Cursor-Based Pagination: The Guiding Star

Traditional offset-based pagination (skip and take) is inefficient for large, dynamic datasets. Calculating an offset requires the database to scan and discard all previous rows, which becomes exponentially slower as the offset grows. For instance, fetching page 1000 (OFFSET 9990) is significantly more expensive than fetching page 1.

Cursor-based pagination solves this by using a pointer to a specific record in the dataset, known as a cursor. Instead of telling the server "give me the third page," the client says "give me the next 20 items after this specific item (identified by its cursor)." The cursor is typically a unique, sequential, and indexed field, such as a createdAt timestamp or a sequential id.

Analogy: Imagine a library with books arranged on shelves in chronological order. Offset-based pagination is like telling the librarian, "Bring me the books from positions 100 to 120." The librarian has to count through the first 99 books to find the right ones. Cursor-based pagination is like giving the librarian a specific book (the cursor) and saying, "Bring me the next 20 books after this one." The librarian can go directly to that book's location and fetch the subsequent ones, which is far more efficient.

This approach is stateful on the server side (it needs to know the last item fetched) but stateless in terms of the client's request. It's resilient to data mutations; if a new item is added, it won't affect the sequence of items after the cursor, preventing duplicates or gaps in the feed.

The Backend for Frontend (BFF) Pattern with tRPC

In a traditional monolithic or REST-based architecture, the frontend would directly call the database or a generic API endpoint to fetch paginated data. However, in a modern, type-safe, and composable architecture, we use a Backend for Frontend (BFF) pattern. Here, a dedicated backend layer (in our case, built with tRPC) serves the specific needs of the frontend application.

tRPC (TypeScript Remote Procedure Call) is a library that allows us to define end-to-end type-safe API procedures. When implementing pagination, tRPC provides a robust contract between the client and the server. The client defines a procedure that accepts a cursor and a limit, and the server returns the data along with a new cursor for the next page.

The BFF layer, powered by tRPC, handles several critical responsibilities:

  1. Data Aggregation & Transformation: It might fetch data from multiple microservices or database tables and combine them into a single, paginated response tailored for the frontend's view.
  2. Authorization & Context: It ensures that the user is only fetching data they are permitted to see, leveraging the server's secure context.
  3. Rate Limiting & Caching: This is where Edge Functions come into play. By deploying tRPC procedures on Edge Functions, we can enforce rate limits at the network edge, close to the user, preventing abuse and ensuring fair usage. We can also implement sophisticated caching strategies (e.g., using Redis) to serve frequently accessed pages without hitting the primary database.

Analogy: Think of the BFF as a personal concierge for your frontend application. The frontend (the guest) tells the concierge (tRPC on Edge Functions) what it needs: "I need the next 20 posts, starting after post #450." The concierge, knowing the guest's preferences and access level, efficiently fetches this from various services (the hotel's kitchen, housekeeping, etc.), applies any necessary transformations (e.g., summarizing long text), and delivers a perfectly prepared response. The guest never has to deal with the complexity of the hotel's internal operations.

Intelligent Data Transformation with LLMs

The final piece of our theoretical puzzle is the integration of Large Language Models (LLMs) to intelligently process the paginated data stream. In a standard pagination setup, the data is simply passed through. However, in a content-rich application, raw data can be overwhelming. This is where LLMs, accessed via Edge Functions, provide immense value.

Consider a feed of articles or long-form posts. Rendering the full text for each item in an infinite scroll can be heavy and may not be what the user wants at a glance. An LLM can be invoked to:

  1. Summarize Content: Generate a concise, one-paragraph summary for each article in the page, allowing the user to quickly scan and decide if they want to read more.
  2. Extract Key Entities: Identify and tag people, places, or topics within the content, enabling richer filtering and search capabilities on the client side.
  3. Transform Data Structure: Convert a complex, nested data object into a flat, UI-friendly structure, reducing the need for heavy client-side processing.

The critical architectural consideration here is where and when to apply this LLM transformation. Performing LLM inference on every single item for every paginated request would be prohibitively expensive and slow. A more intelligent approach is to use a hybrid strategy:

  • On-Demand Transformation: The LLM is invoked only when a user interacts with a specific item (e.g., clicks "Expand" to see a summary). The raw data is fetched initially, and the summary is generated lazily.
  • Pre-computed Summaries: For highly static content, summaries can be generated and stored in the database alongside the original content. The BFF then simply fetches the pre-computed summary, avoiding real-time LLM costs.
  • Edge-Optimized Inference: For dynamic content, the LLM call can be made from the Edge Function serving the tRPC procedure. This keeps the logic close to the data source and the user, minimizing latency. However, this requires careful management of LLM provider rate limits and costs.

Analogy: Imagine you are a librarian (the Edge Function) receiving a request for a list of books (the paginated data). Instead of just handing over the full text of each book, you have an assistant (the LLM) who can quickly read each book and provide a blurb. You can either have the assistant pre-write blurbs for all books in the library (pre-computation), or you can have the assistant write a blurb only when a patron asks for it (on-demand). The smart librarian knows that for a quick scan, a blurb is sufficient, and only fetches the full text when the patron is ready to read deeply.

Architectural Flow: From User Scroll to Rendered Data

To tie these concepts together, let's visualize the end-to-end flow of an infinite scroll implementation with cursor-based pagination and LLM transformation.

The user initiates the scroll. The frontend client, detecting that the user is nearing the bottom of the viewport, triggers a tRPC query. This query is sent to an Edge Function. The Edge Function, acting as our BFF, first checks its rate limits and cache. If the data is not cached, it queries the database using the provided cursor to fetch the next batch of raw data. It then decides whether to invoke an LLM for transformation based on the content type and user request. The transformed data is returned to the client, which appends it to the existing list in the DOM. This cycle repeats as the user continues to scroll.

This diagram illustrates the cyclical nature of the process. Each scroll event initiates a new cycle, but the state (the cursor) is maintained by the client, making the server-side logic simple and scalable. The integration of Edge Functions ensures that each step is optimized for low latency and high availability, while the LLM provides an intelligent layer of data curation that enhances the user experience without overwhelming the client. By mastering these theoretical foundations, we can build interfaces that are not only performant and scalable but also contextually aware and intelligent.

Basic Code Example

This example demonstrates a minimal, self-contained SaaS backend for fetching paginated user data using tRPC. We will implement a cursor-based pagination strategy, which is superior to offset-based pagination for large, dynamic datasets because it avoids skipping records or showing duplicates when data changes between requests. The frontend will use a simple React component to fetch and display data, simulating an infinite scroll pattern.

The Core Concept: Cursor-Based Pagination

In cursor-based pagination, each request asks for the next "page" of results starting from a specific point (the cursor) in the dataset. The cursor is typically a unique, sequential identifier like a timestamp or an auto-incrementing ID. The client sends the cursor of the last item it received, and the server returns the next set of items after that cursor.

This approach is stateless and efficient for the database, as it can use an indexed column for fast lookups (WHERE id > last_cursor), unlike offset-based pagination which requires scanning all previous rows (LIMIT 10 OFFSET 10000).

Implementation Overview

  1. Backend (tRPC): We define a tRPC router that accepts a cursor and a limit. It queries a mock database (an in-memory array for simplicity) to fetch the next limit items after the given cursor. The response includes the data and a nextCursor for the subsequent request.
  2. Frontend (React): We use a custom hook useInfiniteUsers that leverages tRPC's built-in useInfiniteQuery hook. This hook manages the state of pages, cursors, and data concatenation automatically.
  3. UI: A simple list component that renders the fetched users and a loading indicator.

Code Example

// File: src/server/api/routers/user.ts
// This file contains the tRPC router for user pagination.

import { z } from 'zod';
import { publicProcedure, createTRPCRouter } from '~/server/api/trpc';

// Mock database: A simple array of user objects.
// In a real application, this would be a database query (e.g., Prisma, Drizzle).
const mockUsers = Array.from({ length: 100 }, (_, i) => ({
  id: `user_${i + 1}`,
  name: `User ${i + 1}`,
  email: `user${i + 1}@example.com`,
  createdAt: new Date(Date.now() - i * 60000), // Created 1 minute apart, descending.
}));

export const userRouter = createTRPCRouter({
  // The infinite query procedure for pagination.
  getInfiniteUsers: publicProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(10),
        cursor: z.string().optional(), // The cursor is the ID of the last item.
      })
    )
    .query(async ({ input }) => {
      const { limit, cursor } = input;

      // 1. Find the index of the cursor item in our mock data.
      //    If no cursor is provided, we start from the beginning (index -1).
      const cursorIndex = cursor
        ? mockUsers.findIndex((u) => u.id === cursor)
        : -1;

      // 2. Calculate the start index for the next page.
      const startIndex = cursorIndex + 1;

      // 3. Slice the array to get the next 'limit' items.
      const items = mockUsers.slice(startIndex, startIndex + limit);

      // 4. Determine the next cursor.
      //    If we have more items after the current slice, the next cursor is the ID of the last item in the slice.
      //    Otherwise, there is no next cursor (end of list).
      const nextCursor = items.length === limit ? items[items.length - 1].id : undefined;

      // 5. Return the items and the next cursor.
      return {
        items,
        nextCursor,
      };
    }),
});
// File: src/components/UserList.tsx
// A React component that uses tRPC's infinite query to fetch and display users.

import { api } from '~/utils/api'; // Assuming a standard tRPC setup with `@trpc/react-query`
import { Fragment } from 'react';

export function UserList() {
  // Use the `useInfiniteQuery` hook from tRPC.
  // It automatically handles fetching, state management, and data concatenation.
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } =
    api.user.getInfiniteUsers.useInfiniteQuery(
      { limit: 10 }, // Initial limit and no cursor.
      {
        // This function is called to get the next page's parameters.
        getNextPageParam: (lastPage) => lastPage.nextCursor,
      }
    );

  if (isLoading) {
    return <div>Loading initial users...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  // Flatten all pages into a single list of users.
  const allUsers = data?.pages.flatMap((page) => page.items) ?? [];

  return (
    <div className="max-w-md mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">User List (Infinite Scroll)</h1>
      <ul className="space-y-2">
        {allUsers.map((user) => (
          <li key={user.id} className="p-3 bg-gray-100 rounded-md">
            <div className="font-semibold">{user.name}</div>
            <div className="text-sm text-gray-600">{user.email}</div>
          </li>
        ))}
      </ul>

      {/* Load More Button */}
      <div className="mt-4 text-center">
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetchingNextPage}
          className="px-4 py-2 bg-blue-500 text-white rounded disabled:bg-gray-400"
        >
          {isFetchingNextPage
            ? 'Loading more...'
            : hasNextPage
            ? 'Load More'
            : 'Nothing more to load'}
        </button>
      </div>
    </div>
  );
}

Detailed Line-by-Line Explanation

Backend: user.ts

  1. import { z } from 'zod';

    • Why: Zod is a TypeScript-first schema validation library. tRPC uses it to validate and type-check incoming input from the client at runtime, ensuring data integrity and generating automatic TypeScript types.
    • How: We define a schema for the input object of our tRPC procedure.
  2. const mockUsers = ...

    • Why: For this "Hello World" example, we need a self-contained data source. A real application would connect to a database (PostgreSQL, MongoDB, etc.).
    • How: We create an array of 100 user objects. The createdAt field is simulated to be sequential, which is a common pattern for cursor-based pagination (using timestamps). Here, we use the id as the cursor for simplicity.
  3. export const userRouter = createTRPCRouter({...})

    • Why: tRPC organizes procedures into routers, creating a modular and type-safe API structure.
    • How: We define a router named userRouter.
  4. .input(z.object({ limit: z.number(), cursor: z.string().optional() }))

    • Why: This defines the shape of the data the client must send. The limit controls how many items to fetch, and the cursor identifies the starting point.
    • How: We use Zod to define an object schema. z.number().min(1).max(100) adds validation rules. z.string().optional() allows the cursor to be undefined for the first request.
  5. .query(async ({ input }) => { ... })

    • Why: This is the core server-side logic. The query method defines a read-only endpoint.
    • How: The function receives the validated input object. It's an async function, allowing for database calls (though we use a synchronous slice here for simplicity).
  6. const cursorIndex = cursor ? mockUsers.findIndex(...) : -1;

    • Why: We need to find where our data slice should start. If a cursor is provided, we find the index of that item in our array. If not, we start from the beginning (index -1, so startIndex becomes 0).
    • How: Array.findIndex returns the index of the first element that satisfies the condition. If no cursor is given, we default to -1.
  7. const startIndex = cursorIndex + 1;

    • Why: The cursor points to the last item the client already has. We want the next item, so we start one index after the cursor's position.
  8. const items = mockUsers.slice(startIndex, startIndex + limit);

    • Why: This extracts the next page of data. Array.slice is a standard JavaScript method for extracting a portion of an array.
    • How: We take limit items starting from startIndex.
  9. const nextCursor = items.length === limit ? items[items.length - 1].id : undefined;

    • Why: This is the critical logic for pagination control. We need to tell the client if there's more data.
    • How: If the number of items returned (items.length) equals the requested limit, it's highly likely there are more items in the database. We therefore set the nextCursor to the ID of the last item in the current slice. If we got fewer items than the limit, we've reached the end, so nextCursor is undefined.
  10. return { items, nextCursor };

    • Why: The response must contain both the data and the pagination metadata.
    • How: We return an object that tRPC will serialize and send to the client.

Frontend: UserList.tsx

  1. import { api } from '~/utils/api';

    • Why: This is the standard tRPC client, generated by @trpc/react-query. It provides a proxy object (api) that mirrors the server's router structure.
  2. const { data, fetchNextPage, ... } = api.user.getInfiniteUsers.useInfiniteQuery(...)

    • Why: This is the heart of the frontend pagination logic. useInfiniteQuery is a tRPC hook built on top of TanStack Query's infinite query feature. It handles fetching, caching, and state management for paginated data.
    • How:
      • { limit: 10 }: The initial input for the first page.
      • getNextPageParam: (lastPage) => lastPage.nextCursor: This function is essential. It tells the hook how to get the cursor for the next page. It receives the result of the last successful fetch (lastPage), and we extract the nextCursor we defined on the server. If nextCursor is undefined, hasNextPage will become false.
  3. if (isLoading) { ... }

    • Why: We need to provide feedback to the user while the initial data is being fetched.
    • How: The isLoading state is true only for the very first fetch. It's distinct from isFetchingNextPage.
  4. const allUsers = data?.pages.flatMap((page) => page.items) ?? [];

    • Why: The data object from useInfiniteQuery has a pages array. Each element in pages is the response from a single fetch (e.g., { items: [...], nextCursor: '...' }). We need to flatten this into a single array of users to render.
    • How: flatMap is perfect for this. It maps each page to its items array and then flattens the result into a single level. The ?? [] provides a safe fallback if data is not yet available.
  5. <button onClick={() => fetchNextPage()} ...>

    • Why: This button triggers the fetching of the next page.
    • How: fetchNextPage is a function returned by the hook. When called, it automatically uses the getNextPageParam logic to determine the cursor for the next request and calls the tRPC procedure again. The new data is appended to the data.pages array.
  6. disabled={!hasNextPage || isFetchingNextPage}

    • Why: This provides a robust user experience. We disable the button if there are no more pages to load (!hasNextPage) or if a request is already in progress (isFetchingNextPage).
    • How: These boolean flags are provided directly by the useInfiniteQuery hook.

Visualizing the Data Flow

The following diagram illustrates the cyclical nature of the client-server interaction in an infinite scroll pattern.

The diagram illustrates the cyclical data flow in an infinite scroll pattern, where the useInfiniteQuery hook manages boolean flags to handle client-server interactions for fetching subsequent pages of data.
Hold "Ctrl" to enable pan & zoom

The diagram illustrates the cyclical data flow in an infinite scroll pattern, where the `useInfiniteQuery` hook manages boolean flags to handle client-server interactions for fetching subsequent pages of data.

Common Pitfalls

  1. Vercel/AWS Lambda Timeouts (Serverless Functions):

    • Issue: Serverless functions have execution time limits (e.g., 10 seconds on Vercel's Hobby plan). If your database query for a single page is slow, or if you are performing complex data transformations (like LLM summarization mentioned in the chapter title) on a large page of data, the function can time out.
    • Solution:
      • Optimize Queries: Ensure your database columns used for cursors (e.g., id, createdAt) are indexed.
      • Keep Logic Lean: Avoid heavy, synchronous processing inside the tRPC procedure. If you need to transform data, consider doing it on the client or using a separate, asynchronous background job.
      • Increase Limits: If necessary, upgrade your serverless plan to allow for longer execution times, but this should be a last resort.
  2. Stale Data & Race Conditions:

    • Issue: If new items are added to the database while a user is scrolling, the cursor-based pagination might skip items or show duplicates. For example, if the user is on page 2 (cursor=50) and a new item with ID 51 is inserted, the next page might start incorrectly.
    • Solution: This is a classic problem. For most SaaS applications, it's acceptable. For highly dynamic feeds (like a social media timeline), you might need a more complex strategy like "time-based" cursors or a "reverse" pagination where you fetch items before a certain timestamp. For this basic example, the simple ID cursor is sufficient.
  3. Incorrect getNextPageParam Logic:

    • Issue: A common mistake is returning the cursor of the first item of the next page instead of the last item of the current page. This can cause the next fetch to skip the first item of the new page.
    • Solution: The logic must be: nextCursor = lastItemInCurrentPage.id. Our code items[items.length - 1].id correctly implements this. Always double-check this function, as it's the core of the pagination logic.
  4. Async/Await Misuse in Loops (Advanced):

    • Issue: When fetching data for infinite scroll, developers might be tempted to use for...of loops with await inside to pre-fetch multiple pages. This is highly inefficient as it fetches sequentially, blocking the UI and increasing load times.
    • Solution: Use the built-in useInfiniteQuery hook as shown in the example. It is optimized by TanStack Query to handle background refetching and caching. If you must fetch manually, use Promise.all for parallel requests, but be mindful of rate limits.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.