Pagination prevents loading entire datasets at once. This guide covers pagination strategies with their tradeoffs.
Offset Pagination#
Simple but has performance issues at scale:
1// API endpoint
2app.get('/api/posts', async (req, res) => {
3 const page = parseInt(req.query.page as string) || 1;
4 const limit = parseInt(req.query.limit as string) || 20;
5 const offset = (page - 1) * limit;
6
7 const [posts, total] = await Promise.all([
8 db.posts.findMany({
9 skip: offset,
10 take: limit,
11 orderBy: { createdAt: 'desc' },
12 }),
13 db.posts.count(),
14 ]);
15
16 res.json({
17 data: posts,
18 pagination: {
19 page,
20 limit,
21 total,
22 totalPages: Math.ceil(total / limit),
23 hasNext: page * limit < total,
24 hasPrev: page > 1,
25 },
26 });
27});-- SQL query
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40;
-- Problem: Database still scans offset rows
-- OFFSET 10000 means scanning 10000 rows to skip themWhen to Use#
- Small datasets (< 10,000 rows)
- Need to jump to specific pages
- Simple UI requirements
Cursor-Based Pagination#
Better performance for large datasets:
1interface CursorPaginationParams {
2 cursor?: string;
3 limit?: number;
4 direction?: 'forward' | 'backward';
5}
6
7app.get('/api/posts', async (req, res) => {
8 const { cursor, limit = 20 } = req.query;
9
10 const posts = await db.posts.findMany({
11 take: limit + 1, // Fetch one extra to check if more exist
12 cursor: cursor ? { id: cursor } : undefined,
13 skip: cursor ? 1 : 0, // Skip the cursor itself
14 orderBy: { createdAt: 'desc' },
15 });
16
17 const hasMore = posts.length > limit;
18 const data = hasMore ? posts.slice(0, -1) : posts;
19
20 res.json({
21 data,
22 pagination: {
23 nextCursor: hasMore ? data[data.length - 1].id : null,
24 hasMore,
25 },
26 });
27});Encoding Cursors#
1// Encode cursor to hide implementation details
2function encodeCursor(data: object): string {
3 return Buffer.from(JSON.stringify(data)).toString('base64url');
4}
5
6function decodeCursor(cursor: string): object {
7 return JSON.parse(Buffer.from(cursor, 'base64url').toString());
8}
9
10// Usage
11const cursor = encodeCursor({
12 id: post.id,
13 createdAt: post.createdAt.toISOString(),
14});
15
16// Decode on next request
17const { id, createdAt } = decodeCursor(req.query.cursor);Bidirectional Cursor Pagination#
1app.get('/api/posts', async (req, res) => {
2 const { cursor, direction = 'forward', limit = 20 } = req.query;
3
4 let posts;
5 const cursorData = cursor ? decodeCursor(cursor) : null;
6
7 if (direction === 'forward') {
8 posts = await db.posts.findMany({
9 where: cursorData ? {
10 createdAt: { lt: new Date(cursorData.createdAt) },
11 } : undefined,
12 take: limit + 1,
13 orderBy: { createdAt: 'desc' },
14 });
15 } else {
16 posts = await db.posts.findMany({
17 where: cursorData ? {
18 createdAt: { gt: new Date(cursorData.createdAt) },
19 } : undefined,
20 take: limit + 1,
21 orderBy: { createdAt: 'asc' },
22 });
23 posts.reverse();
24 }
25
26 const hasMore = posts.length > limit;
27 const data = hasMore ? posts.slice(0, -1) : posts;
28
29 res.json({
30 data,
31 pagination: {
32 nextCursor: hasMore ? encodeCursor({
33 id: data[data.length - 1].id,
34 createdAt: data[data.length - 1].createdAt,
35 }) : null,
36 prevCursor: data.length > 0 ? encodeCursor({
37 id: data[0].id,
38 createdAt: data[0].createdAt,
39 }) : null,
40 },
41 });
42});Keyset Pagination#
Most efficient for sorted data:
1// Using multiple columns for stable ordering
2app.get('/api/posts', async (req, res) => {
3 const { lastCreatedAt, lastId, limit = 20 } = req.query;
4
5 const whereClause = lastCreatedAt && lastId
6 ? {
7 OR: [
8 { createdAt: { lt: new Date(lastCreatedAt) } },
9 {
10 createdAt: new Date(lastCreatedAt),
11 id: { lt: lastId },
12 },
13 ],
14 }
15 : undefined;
16
17 const posts = await db.posts.findMany({
18 where: whereClause,
19 take: limit + 1,
20 orderBy: [
21 { createdAt: 'desc' },
22 { id: 'desc' },
23 ],
24 });
25
26 const hasMore = posts.length > limit;
27 const data = hasMore ? posts.slice(0, -1) : posts;
28 const lastPost = data[data.length - 1];
29
30 res.json({
31 data,
32 pagination: {
33 hasMore,
34 nextParams: hasMore ? {
35 lastCreatedAt: lastPost.createdAt.toISOString(),
36 lastId: lastPost.id,
37 } : null,
38 },
39 });
40});-- Efficient SQL with index on (created_at DESC, id DESC)
SELECT * FROM posts
WHERE (created_at, id) < ('2024-01-15 10:30:00', 'abc123')
ORDER BY created_at DESC, id DESC
LIMIT 20;GraphQL Pagination (Relay Style)#
1type Query {
2 posts(first: Int, after: String, last: Int, before: String): PostConnection!
3}
4
5type PostConnection {
6 edges: [PostEdge!]!
7 pageInfo: PageInfo!
8 totalCount: Int
9}
10
11type PostEdge {
12 cursor: String!
13 node: Post!
14}
15
16type PageInfo {
17 hasNextPage: Boolean!
18 hasPreviousPage: Boolean!
19 startCursor: String
20 endCursor: String
21}1// Resolver implementation
2const postsResolver = async (_, args) => {
3 const { first, after, last, before } = args;
4 const limit = first || last || 20;
5
6 const posts = await fetchPosts({ after, before, limit: limit + 1 });
7
8 const hasMore = posts.length > limit;
9 const nodes = hasMore ? posts.slice(0, -1) : posts;
10
11 return {
12 edges: nodes.map(post => ({
13 cursor: encodeCursor({ id: post.id }),
14 node: post,
15 })),
16 pageInfo: {
17 hasNextPage: first ? hasMore : false,
18 hasPreviousPage: last ? hasMore : false,
19 startCursor: nodes[0] ? encodeCursor({ id: nodes[0].id }) : null,
20 endCursor: nodes.length ? encodeCursor({ id: nodes[nodes.length - 1].id }) : null,
21 },
22 };
23};Frontend Implementation#
React with Infinite Scroll#
1function PostList() {
2 const [posts, setPosts] = useState<Post[]>([]);
3 const [cursor, setCursor] = useState<string | null>(null);
4 const [hasMore, setHasMore] = useState(true);
5 const [loading, setLoading] = useState(false);
6
7 const loadMore = async () => {
8 if (loading || !hasMore) return;
9
10 setLoading(true);
11 const params = new URLSearchParams({ limit: '20' });
12 if (cursor) params.set('cursor', cursor);
13
14 const response = await fetch(`/api/posts?${params}`);
15 const { data, pagination } = await response.json();
16
17 setPosts(prev => [...prev, ...data]);
18 setCursor(pagination.nextCursor);
19 setHasMore(pagination.hasMore);
20 setLoading(false);
21 };
22
23 // Intersection Observer for infinite scroll
24 const observerRef = useRef<IntersectionObserver>();
25 const lastPostRef = useCallback((node: HTMLElement | null) => {
26 if (loading) return;
27 if (observerRef.current) observerRef.current.disconnect();
28
29 observerRef.current = new IntersectionObserver(entries => {
30 if (entries[0].isIntersecting && hasMore) {
31 loadMore();
32 }
33 });
34
35 if (node) observerRef.current.observe(node);
36 }, [loading, hasMore]);
37
38 return (
39 <div>
40 {posts.map((post, index) => (
41 <PostCard
42 key={post.id}
43 post={post}
44 ref={index === posts.length - 1 ? lastPostRef : null}
45 />
46 ))}
47 {loading && <Spinner />}
48 </div>
49 );
50}Comparison#
| Method | Performance | Jump to Page | Consistency |
|---|---|---|---|
| Offset | Poor at scale | Yes | Inconsistent |
| Cursor | Good | No | Consistent |
| Keyset | Excellent | No | Consistent |
Conclusion#
Use offset pagination for simple cases with small datasets. Switch to cursor or keyset pagination for large datasets or real-time data. Cursor pagination provides the best balance of performance and usability for most applications.