How many pagination strategies are there? I've heard of
keyset pagination and offset-based
You’ve got the two classics already in hand: offset-based (skip/limit) and keyset (a.k.a. cursor-based) pagination. But the world of pagination has a few more tricks depending on what trade-offs you care about (speed, consistency, UX). Here’s a quick map:
Offset-based pagination
Skip X, take Y.
Simple, flexible, but gets slower on large datasets because the database has to scan through rows.
Keyset / Cursor-based pagination
Use a stable ordering key (like
id
or
created_at
) and fetch rows after/before that.
Very fast and consistent, but you lose “random access” (you can’t just jump to page 57).
Seek pagination
Often considered the same as keyset, but sometimes distinguished when you seek on multiple columns or a compound index.
Hybrid approaches
Example: use offset for small page jumps (page 1–5), then switch to cursors when users are deep in the dataset.
Time-based pagination
For data that naturally flows by time (like logs, chat messages, feeds), you paginate using timestamps instead of arbitrary IDs.
Scrollable cursors (server-maintained state)
The server/database holds a cursor handle and the client fetches more by keeping that handle alive. Very common in DB drivers, but heavier on resources.
Relay-style connections (GraphQL)
Built on cursor-based, but adds “edges” and
“connections” with
before
/after
/first
/last
arguments for more flexibility.
So depending on how you slice it, you can say there are a handful of distinct strategies, but they’re all riffs on two main families: offset vs cursor/seek.
Would you like me to break down when you’d actually want to use each one, or are you just mapping the landscape right now?
Nice, rolling up sleeves time 🛠️. Since you asked for
implementations, I’ll sketch
example Node.js snippets
for each style. I’ll assume we’re working with
Express + a SQL database (PostgreSQL)
via pg
, but
the patterns translate fine to ORMs like Sequelize/Prisma
or even Mongo.
js// GET /users?page=2&limit=10 app.get("/users", async (req, res) => { const { page = 1, limit = 10 } = req.query; const offset = (page - 1) * limit; const { rows } = await db.query( "SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2", [limit, offset] ); res.json(rows); });
✅ Easy, but slow for huge offsets.
js// GET /users?limit=10&after=42 app.get("/users", async (req, res) => { const { limit = 10, after } = req.query; let query = "SELECT * FROM users"; let params = [limit]; if (after) { query += " WHERE id > $2"; params.push(after); } query += " ORDER BY id ASC LIMIT $1"; const { rows } = await db.query(query, params); res.json(rows); });
✅ Very fast, consistent ordering.
🚫 Can’t jump to arbitrary pages.
js// GET /posts?limit=10&afterId=100&afterCreated=2023-01-01 app.get("/posts", async (req, res) => { const { limit = 10, afterId, afterCreated } = req.query; let query = ` SELECT * FROM posts WHERE (created_at, id) > ($2, $3) ORDER BY created_at, id LIMIT $1 `; const { rows } = await db.query(query, [limit, afterCreated, afterId]); res.json(rows); });
✅ Handles ties in sorting by using multiple columns.
js// Offset for shallow pages, cursor for deeper ones app.get("/users", async (req, res) => { const { page = 1, limit = 10, after } = req.query; if (page <= 5) { const offset = (page - 1) * limit; const { rows } = await db.query( "SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2", [limit, offset] ); return res.json(rows); } if (after) { const { rows } = await db.query( "SELECT * FROM users WHERE id > $2 ORDER BY id LIMIT $1", [limit, after] ); return res.json(rows); } res.status(400).json({ error: "Need 'after' for deep pages" }); });
js// GET /logs?limit=50&before=2023-09-01T00:00:00Z app.get("/logs", async (req, res) => { const { limit = 50, before } = req.query; const { rows } = await db.query( "SELECT * FROM logs WHERE created_at < $2 ORDER BY created_at DESC LIMIT $1", [limit, before || new Date().toISOString()] ); res.json(rows); });
✅ Great for logs/feeds.
js// Imagine a database cursor you keep open app.get("/stream-users", async (req, res) => { const client = await db.connect(); await client.query("BEGIN"); // open cursor await client.query("DECLARE user_cursor CURSOR FOR SELECT * FROM users ORDER BY id"); // fetch a batch const { rows } = await client.query("FETCH 20 FROM user_cursor"); // cleanup: COMMIT to close cursor await client.query("COMMIT"); client.release(); res.json(rows); });
✅ Efficient streaming.
🚫 State is server-bound, harder to scale.
js// GraphQL schema (simplified) type UserConnection { edges: [UserEdge] pageInfo: PageInfo } type UserEdge { cursor: String node: User } type PageInfo { hasNextPage: Boolean endCursor: String }
js// Resolver const users = async (_, { first, after }) => { let query = "SELECT * FROM users WHERE id > $2 ORDER BY id ASC LIMIT $1"; const { rows } = await db.query(query, [first, after || 0]); return { edges: rows.map(u => ({ cursor: u.id.toString(), node: u })), pageInfo: { hasNextPage: rows.length === first, endCursor: rows.length ? rows[rows.length - 1].id.toString() : null, }, }; };
That’s the full set 🎯. Each of these comes with trade-offs: offset = easy but slow, cursor = fast but strict order, scrollable = powerful but sticky, etc.
Do you want me to wrap these into a single Express server demo with routes for each style, or keep them as standalone snippets like above?
That’s the interesting bit: keyset pagination shines when you don’t actually care about jumping to page 57 but instead care about smooth, continuous navigation. Think about use-cases where people scroll “next, next, next” or keep swiping down a feed:
Infinite scrolling feeds (Twitter/X timeline, Instagram posts, Reddit threads) → Users never type in “page 23,” they just keep scrolling.
Activity streams / logs → You usually want “give me everything newer than X” or “older than Y,” not “page 10.”
Chat apps → You load messages after the last one you saw or before the first one in view. Random jumping is rare.
Financial data / transactions → Order matters (by time or ID), and consistency is critical—offsets can drift if new records insert while paging.
APIs → Many APIs (GitHub, GraphQL Relay, Stripe, etc.) use cursor-based pagination because it’s efficient and stable across large datasets.
So, in short:
If your users browse like a book (go to page 1, page 10, page 57), offset fits better.
If they scroll like a feed (load more, keep going, refresh latest), keyset is the winner.
Want me to sketch a mental picture comparing the “book” model (offset) vs the “endless conveyor belt” model (keyset) so it’s easier to see which fits where?