Back to Blog
PaginationAPIDatabasePerformance

Pagination Patterns: Efficiently Loading Large Datasets

Implement pagination for APIs and UIs. Learn offset, cursor, and keyset pagination with performance considerations.

B
Bootspring Team
Engineering
February 27, 2026
6 min read

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 them

When 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#

MethodPerformanceJump to PageConsistency
OffsetPoor at scaleYesInconsistent
CursorGoodNoConsistent
KeysetExcellentNoConsistent

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.

Share this article

Help spread the word about Bootspring