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. πŸš€