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.
On this page · 7 min · Intermediate
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Intermediate
- Updated
- Oct 28, 2026
Related tutorials
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#
-- 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#
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();
});
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 = nullThis example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- clamp() turns query strings into numbers with a default and a maximum.
- Offset pagination computes OFFSET from the page and returns total and totalPages.
- A pageSize of 1000 is capped at 50, protecting the database.
- Cursor pagination asks for limit + 1 rows; the extra row means another page exists.
- 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#
?pageSize=1000000 becomes an easy denial-of-service. Always cap it.
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).
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?
Show solution
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#
What SQL does page 4 with pageSize 25 need?
Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions
My notes#
Saved in this browser.