Node.js REST API · Data

Pagination

Never return all 50,000 courses at once. Pagination returns a page at a time. Offset pagination (page 3 of 12) is simple; cursor pagination (“give me the next 10 after this one”) stays fast and stable on huge, changing tables.

Intermediate 7 min Lesson 11 of 25
Before you start:CRUD APIs
0/25 in Node.js REST API
On this page · 7 min · Intermediate

Learning objectives#

  • Implement offset pagination with LIMIT/OFFSET and metadata
  • Implement cursor (keyset) pagination with opaque cursors
  • Cap page sizes
  • Choose the right strategy for a use case

Explanation#

Offset: ?page=3&pageSize=10 → LIMIT 10 OFFSET 20. Return metadata: page, pageSize, total, totalPages. Easy to jump to page 7 — great for admin tables.

Offset problems: MySQL still reads and discards OFFSET rows (slow at page 5,000), and if rows are inserted while a user pages, items shift and appear twice or get skipped.

Cursor (keyset): ?limit=10&after=<cursor> → WHERE id > :lastId ORDER BY id LIMIT 11. Using the index, it is fast at any depth and stable under inserts. Fetch one extra row to know whether a next page exists.

Make cursors opaque: base64url-encode { id } (or { createdAt, id } for other sort orders) so clients do not build them by hand and you can change the format later.

Always cap the page size (e.g. max 50) and give a default. Return a consistent envelope: { data, meta } or { data, nextCursor }.

Syntax#

SQL Illustrative
-- offset
SELECT id, title FROM courses ORDER BY id LIMIT 10 OFFSET 20;
SELECT COUNT(*) AS total FROM courses;


-- keyset / cursor
SELECT id, title FROM courses WHERE id > 20 ORDER BY id LIMIT 11;

Code example: Offset and cursor pagination side by side#

JavaScriptpagination.js Illustrative
import express from 'express';
import mysql from 'mysql2';            // used only for mysql.format, to show the SQL each request would run


const courses = Array.from({ length: 23 }, (_, i) => ({ id: i + 1, title: `Course ${i + 1}` }));
const clamp = (value, min, max, fallback) => Math.min(Math.max(Number(value) || fallback, min), max);


const app = express();


// Offset pagination: /api/courses?page=3&pageSize=10
app.get('/api/courses', (req, res) => {
  const page = clamp(req.query.page, 1, 10_000, 1);
  const pageSize = clamp(req.query.pageSize, 1, 50, 10);
  const offset = (page - 1) * pageSize;
  console.log('  SQL:', mysql.format('SELECT id, title FROM courses ORDER BY id LIMIT ? OFFSET ?', [pageSize, offset]));
  res.json({
    data: courses.slice(offset, offset + pageSize),
    meta: { page, pageSize, total: courses.length, totalPages: Math.ceil(courses.length / pageSize) },
  });
});


// Cursor pagination: /api/feed?limit=10&after=<cursor>
const encodeCursor = id => Buffer.from(JSON.stringify({ id })).toString('base64url');
const decodeCursor = cursor => JSON.parse(Buffer.from(cursor, 'base64url').toString()).id;


app.get('/api/feed', (req, res) => {
  const limit = clamp(req.query.limit, 1, 50, 10);
  const afterId = req.query.after ? decodeCursor(req.query.after) : 0;
  console.log('  SQL:', mysql.format('SELECT id, title FROM courses WHERE id > ? ORDER BY id LIMIT ?', [afterId, limit + 1]));
  const rows = courses.filter(c => c.id > afterId).slice(0, limit + 1);   // one extra row = "is there more?"
  const data = rows.slice(0, limit);
  res.json({ data, nextCursor: rows.length > limit ? encodeCursor(data.at(-1).id) : null });
});


const server = app.listen(3108, async () => {
  const get = async path => (await fetch('http://localhost:3108' + path)).json();


  console.log('Offset, page 3:');
  const page = await get('/api/courses?page=3&pageSize=10');
  console.log('  ids', page.data.map(c => c.id).join(','), JSON.stringify(page.meta));


  console.log('Offset, pageSize=1000 is capped:');
  console.log('  pageSize =', (await get('/api/courses?pageSize=1000')).meta.pageSize);


  console.log('Cursor, following nextCursor:');
  let cursor = null;
  do {
    const feed = await get('/api/feed?limit=10' + (cursor ? '&after=' + cursor : ''));
    console.log(`  ids ${feed.data[0].id}..${feed.data.at(-1).id}, nextCursor = ${feed.nextCursor}`);
    cursor = feed.nextCursor;
  } while (cursor);
  server.close();
});
Expected output
Offset, page 3:
  SQL: SELECT id, title FROM courses ORDER BY id LIMIT 10 OFFSET 20
  ids 21,22,23 {"page":3,"pageSize":10,"total":23,"totalPages":3}
Offset, pageSize=1000 is capped:
  SQL: SELECT id, title FROM courses ORDER BY id LIMIT 50 OFFSET 0
  pageSize = 50
Cursor, following nextCursor:
  SQL: SELECT id, title FROM courses WHERE id > 0 ORDER BY id LIMIT 11
  ids 1..10, nextCursor = eyJpZCI6MTB9
  SQL: SELECT id, title FROM courses WHERE id > 10 ORDER BY id LIMIT 11
  ids 11..20, nextCursor = eyJpZCI6MjB9
  SQL: SELECT id, title FROM courses WHERE id > 20 ORDER BY id LIMIT 11
  ids 21..23, nextCursor = null

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. clamp() turns query strings into numbers with a default and a maximum.
  2. Offset pagination computes OFFSET from the page and returns total and totalPages.
  3. A pageSize of 1000 is capped at 50, protecting the database.
  4. Cursor pagination asks for limit + 1 rows; the extra row means another page exists.
  5. The cursor is an opaque base64url string encoding the last id; null means the end.

Real-world use case#

LearnHub’s admin course table uses offset pagination so staff can jump to any page. The student activity feed — millions of rows, constantly growing — uses cursor pagination for infinite scroll.

Common mistakes#

No maximum page size

?pageSize=1000000 becomes an easy denial-of-service. Always cap it.

Paginating without ORDER BY

Without a stable order, MySQL may return rows in any order and pages overlap. Order by a unique column (or add id as a tie-breaker).

Deep OFFSET on huge tables

OFFSET 1,000,000 reads a million rows to discard them. Use keyset pagination for deep or infinite lists.

Best practices#

  • Default and maximum page size
  • Stable ORDER BY with a unique tie-breaker
  • Offset for jumpable admin lists, cursor for feeds
  • Opaque cursors and a consistent envelope

Remember this#

Offset: LIMIT size OFFSET (page − 1) × size.

Cursor: WHERE id > last ORDER BY id LIMIT size + 1.

Always cap pageSize.

Practice exercise#

Your task: Write buildLinkHeader(baseUrl, page, totalPages) that returns a Link header with next and prev links for offset pagination (omit prev on page 1 and next on the last page).

Need a hint?
Format: <url?page=4>; rel="next", <url?page=2>; rel="prev"
Show solution
JavaScript Illustrative
function buildLinkHeader(baseUrl, page, totalPages) {
  const links = [];
  if (page < totalPages) links.push(`<${baseUrl}?page=${page + 1}>; rel="next"`);
  if (page > 1) links.push(`<${baseUrl}?page=${page - 1}>; rel="prev"`);
  return links.join(', ');
}


console.log(buildLinkHeader('/api/courses', 1, 3));   // </api/courses?page=2>; rel="next"
console.log(buildLinkHeader('/api/courses', 3, 3));   // </api/courses?page=2>; rel="prev"

Mini quiz#

Pagination quizMultiple choice Question 1 of 3

What SQL does page 4 with pageSize 25 need?

Score so far: 0/0

Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.