DEV Community

Cover image for Pagination โ€” Architecture Series: Part 1
MUHAMMAD USMAN AWAN
MUHAMMAD USMAN AWAN

Posted on

Pagination โ€” Architecture Series: Part 1

๐Ÿš€ Pagination โ€” The Complete MERN Stack Guide

In large-scale applications, managing massive datasets efficiently is critical. Whether itโ€™s displaying hundreds of blog posts, thousands of users, or millions of transactions, fetching everything at once is both impractical and wasteful.
Pagination is the architectural pattern that solves thisโ€”by dividing data into discrete, manageable pages for optimal performance, scalability, and user experience.

In this article, weโ€™ll break down the WHAT, WHY, and HOW of pagination โ€” covering both backend and frontend implementations, exploring offset-based, cursor-based, and keyset (seek) strategies. Youโ€™ll also learn about edge cases, performance tuning, database indexing, and best practices used in production systems.

๐Ÿ”น 1. What is Pagination?

Pagination means dividing large datasets into smaller, digestible pieces (pages).
Instead of sending 10,000 records in one response, we send, for example, 10 or 20 per request.

Real-world analogy:

Google doesnโ€™t show all results at once โ€” it shows 10 per page with โ€œNextโ€ & โ€œPrevโ€.

๐Ÿ”น 2. Why Pagination Matters

Reason Description
โšก Performance Limits DB load โ€” query small slices instead of all records
๐Ÿง  Memory Efficiency Prevents browser & server from crashing on large responses
๐Ÿงโ€โ™‚๏ธ Better UX Users digest info easier, faster initial loads
๐Ÿ“ก Bandwidth Reduces unnecessary data transfer
๐Ÿ“ˆ Scalability Apps handle millions of rows smoothly
๐Ÿ” Security & Control Prevents abuse (e.g., scraping entire datasets)

๐Ÿ”น 3. Pagination Types (and When to Use Them)

Type Description Best For
Offset / Page-based page + limit, uses .skip() & .limit() Dashboards, Admin Panels
Cursor-based Uses _id or timestamp to fetch next batch Infinite Scroll, Real-time Feeds
Keyset-based Combines sort + cursor for precise ordering Large ordered datasets

๐Ÿ”น 4. Offset-Based Pagination (Classic)

๐Ÿง  How it works:

You send:
GET /api/users?page=2&limit=10

The server calculates:

skip = (page - 1) * limit
limit = 10
Enter fullscreen mode Exit fullscreen mode

๐Ÿงฉ Backend (Node.js + Express + MongoDB)

import express from "express";
import mongoose from "mongoose";
import User from "./models/User.js"; // assume name, email, createdAt
const app = express();

app.get("/api/users", async (req, res) => {
  try {
    let page = parseInt(req.query.page) || 1;
    let limit = parseInt(req.query.limit) || 10;

    // Validation
    if (page < 1 || limit < 1 || limit > 100) {
      return res.status(400).json({ error: "Invalid pagination params" });
    }

    const skip = (page - 1) * limit;
    const total = await User.countDocuments();

    const users = await User.find()
      .sort({ createdAt: -1 }) // always sort for consistent results
      .skip(skip)
      .limit(limit);

    const totalPages = Math.ceil(total / limit);

    res.json({
      data: users,
      pagination: {
        currentPage: page,
        totalPages,
        totalItems: total,
        itemsPerPage: limit,
        hasNextPage: page < totalPages,
        hasPrevPage: page > 1,
      },
    });
  } catch (err) {
    res.status(500).json({ error: "Server Error" });
  }
});
Enter fullscreen mode Exit fullscreen mode

โš›๏ธ Frontend (React Example)

import { useState, useEffect } from "react";
import axios from "axios";

export default function PaginatedUsers() {
  const [users, setUsers] = useState([]);
  const [pagination, setPagination] = useState({});
  const [loading, setLoading] = useState(false);

  const fetchUsers = async (page = 1) => {
    setLoading(true);
    const res = await axios.get(`/api/users?page=${page}&limit=10`);
    setUsers(res.data.data);
    setPagination(res.data.pagination);
    setLoading(false);
  };

  useEffect(() => {
    fetchUsers(1);
  }, []);

  const goToPage = (p) => {
    if (p >= 1 && p <= pagination.totalPages) fetchUsers(p);
  };

  return (
    <div>
      <h2>Users (Page {pagination.currentPage}/{pagination.totalPages})</h2>
      {loading && <p>Loading...</p>}
      <ul>
        {users.map(u => <li key={u._id}>{u.name} โ€” {u.email}</li>)}
      </ul>

      <div className="flex gap-2 mt-3">
        <button disabled={!pagination.hasPrevPage} onClick={() => goToPage(pagination.currentPage - 1)}>Prev</button>
        <button disabled={!pagination.hasNextPage} onClick={() => goToPage(pagination.currentPage + 1)}>Next</button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

โœ… Pros:

  • Easy to implement
  • Supports jumping to any page

โŒ Cons:

  • skip() becomes slow for large collections (e.g., skip(100000) scans 100k docs)
  • Inconsistent if data changes while paging

๐Ÿ”น 5. Cursor-Based Pagination (Efficient for Feeds)

Instead of page, send a cursor (usually last itemโ€™s _id or timestamp).

Example:

GET /api/users?cursor=652aab234b8f6&limit=10
Enter fullscreen mode Exit fullscreen mode

๐Ÿงฉ Backend (MongoDB + Express)

app.get("/api/users", async (req, res) => {
  try {
    const limit = parseInt(req.query.limit) || 10;
    const cursor = req.query.cursor;

    let query = {};
    if (cursor) query = { _id: { $gt: cursor } };

    const users = await User.find(query)
      .sort({ _id: 1 })
      .limit(limit + 1); // one extra to detect next page

    const hasMore = users.length > limit;
    const sliced = hasMore ? users.slice(0, -1) : users;
    const nextCursor = hasMore ? sliced[sliced.length - 1]._id : null;

    res.json({ 
      data: sliced, 
      pagination: { nextCursor, hasMore } 
    });
  } catch (err) {
    res.status(500).json({ error: "Server Error" });
  }
});
Enter fullscreen mode Exit fullscreen mode

โš›๏ธ Frontend (Infinite Scroll Example)

import { useState, useEffect } from "react";
import axios from "axios";

export default function InfiniteScrollUsers() {
  const [users, setUsers] = useState([]);
  const [nextCursor, setNextCursor] = useState(null);
  const [hasMore, setHasMore] = useState(true);

  const fetchUsers = async () => {
    if (!hasMore) return;
    const url = nextCursor
      ? `/api/users?cursor=${nextCursor}&limit=10`
      : `/api/users?limit=10`;
    const res = await axios.get(url);
    setUsers(prev => [...prev, ...res.data.data]);
    setNextCursor(res.data.pagination.nextCursor);
    setHasMore(res.data.pagination.hasMore);
  };

  useEffect(() => { fetchUsers(); }, []);

  useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore) fetchUsers();
    });
    const sentinel = document.getElementById("sentinel");
    if (sentinel) observer.observe(sentinel);
    return () => observer.disconnect();
  }, [hasMore]);

  return (
    <div>
      {users.map(u => <div key={u._id}>{u.name}</div>)}
      <div id="sentinel">{hasMore ? "Loading more..." : "No more users"}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

โœ… Pros:

  • Scales to millions of records
  • Consistent even when data updates

โŒ Cons:

  • Canโ€™t jump to arbitrary pages (like page 8)
  • Requires ordering by unique key

๐Ÿ”น 6. Keyset Pagination (For Sorted Data)

Used when you sort by a column (like createdAt) and want consistent ordering.

Example Query (MongoDB)

const posts = await Post.find({
  $or: [
    { createdAt: { $lt: lastCreatedAt } },
    { createdAt: lastCreatedAt, _id: { $lt: lastId } }
  ]
})
.sort({ createdAt: -1, _id: -1 })
.limit(limit + 1);
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”น 7. Edge Cases & Best Practices

โœ… Handle Empty Dataset

if (total === 0) return res.json({ data: [], message: "No items found" });
Enter fullscreen mode Exit fullscreen mode

โœ… Out-of-Range Page
If page > totalPages, return empty list or redirect to last page.

โœ… Invalid Inputs

if (isNaN(page) || page < 1) page = 1;
if (limit > 100) limit = 100;
Enter fullscreen mode Exit fullscreen mode

โœ… Deleted or Added Items
Prefer cursor-based pagination if frequent changes happen.

โœ… Indexing

await db.collection("users").createIndex({ createdAt: -1 });
Enter fullscreen mode Exit fullscreen mode

โœ… Limit Deep Pagination

if (page > 100) 
  return res.status(400).json({ error: "Page limit exceeded" });
Enter fullscreen mode Exit fullscreen mode

โœ… Frontend Race Condition

const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort(); // cancel old requests
Enter fullscreen mode Exit fullscreen mode

โœ… URL Syncing
Keep page in URL query to enable reload and shareable links.

โœ… Prefetch Next Page
While user views current page, silently fetch next one in background.

โœ… Accessibility
Add aria-label="Next page" etc., for buttons.

๐Ÿ”น 8. Pagination + Search/Filter

Combine safely:

const { page = 1, limit = 10, search = "" } = req.query;
const regex = new RegExp(search, "i");
const total = await User.countDocuments({ name: regex });
const users = await User.find({ name: regex }).skip(skip).limit(limit);
Enter fullscreen mode Exit fullscreen mode

Always recalculate pagination when filters change.

๐Ÿ”น 9. Performance Tips

โšก Use .lean() in Mongoose to skip hydration (faster):

const users = await User.find().skip(skip).limit(limit).lean();
Enter fullscreen mode Exit fullscreen mode

โšก Cache first page using Redis or in-memory:

if (page === 1) cache.set("users_page1", users);
Enter fullscreen mode Exit fullscreen mode

โšก Paginate at DB level, not in code (avoid slicing arrays in JS).

๐Ÿ”น 10. Choosing the Right Type

Use Case Recommended Type
Admin table Offset
Social feed Cursor
Chat messages Keyset / Cursor
Infinite scroll Cursor
Analytics data Keyset
Static lists (few pages) Offset

โšก Final Summary

Category Concept Backend Frontend Edge Cases
Pagination Type Offset / Cursor / Keyset .skip().limit() / _id / timestamps Paginated or infinite scroll Out-of-range, Empty, Deep pagination
Why Performance, UX, scalability Reduced DB load Faster rendering -
When to Use Always on large datasets Limit to 10โ€“50 per page Provide navigation Reset on filters
Best Practices Validate params, use indexes, cache, sort consistently Use .lean() AbortController, Prefetch Handle concurrent updates

Summary

Pagination may seem simple, but under the hood, itโ€™s a foundational performance pattern every scalable system relies on. From admin dashboards to social media feeds, the way you design your pagination determines how efficiently your application handles growth.

  • Use offset pagination for classic dashboards and tables.
  • Use cursor or keyset pagination for real-time feeds or large datasets.
  • Always validate, cache, and index your queries.
  • Handle empty, deleted, or concurrent updates gracefully.
  • On the frontend, sync pagination state with the URL and ensure responsive, accessible navigation.

This marks the first chapter in the Architecture Series โ€” exploring real-world, production-grade MERN stack scalability patterns.
Next up in Part 2, weโ€™ll go deeper into Caching and Data Layer Optimization โ€” how to reduce redundant queries and speed up response times across the stack.

Top comments (1)

Collapse
 
usman_awan profile image
MUHAMMAD USMAN AWAN

๐Ÿ’ก About This Series

  • This post is part of my MERN Architecture Series, where Iโ€™m exploring how to scale full-stack apps beyond the basics.
  • Weโ€™re starting with Pagination (Part 1) โ€” and next, Iโ€™ll cover Indexing, Caching and Virtualization to make data handling even faster and smarter. ๐Ÿš€