API Pagination: Complete Implementation Guide for Production APIs
API Pagination: Complete Implementation Guide for Production APIs
Pagination is essential for any API that returns lists of data. Without it, your API becomes slow, expensive, and unusable as your data grows. A single unpaginated /users endpoint can bring down your database when it tries to return 10 million records.
This guide covers production-ready pagination patterns used by GitHub, Stripe, and Twitter. You'll learn which pattern to choose, how to implement them efficiently, and common pitfalls that cause performance problems.
Table of Contents
- Why Pagination Matters
- Pagination Patterns Comparison
- Offset-Based Pagination
- Cursor-Based Pagination
- Keyset Pagination
- Page Number Pagination
- Response Format Standards
- Performance Optimization
- Client Implementation
- Common Mistakes
- Production Checklist
Why Pagination Matters
Without pagination:
// ❌ Dangerous: Returns all users (10M records = 2GB response)
app.get('/users', async (req, res) => {
const users = await db.users.findMany();
res.json({ users });
});
Problems:
- Database overload: Fetching millions of rows crashes your database
- Memory exhaustion: Your API server runs out of RAM
- Network timeouts: 2GB responses take minutes to transfer
- Poor UX: Clients can't render 10M items anyway
- Cost: Transferring huge responses burns bandwidth budget
With pagination:
// ✅ Safe: Returns 20 users per page
app.get('/users', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = parseInt(req.query.offset) || 0;
const [users, total] = await Promise.all([
db.users.findMany({ skip: offset, take: limit }),
db.users.count()
]);
res.json({
data: users,
pagination: {
offset,
limit,
total,
hasMore: offset + limit < total
}
});
});
Benefits:
- Database protection: Only fetch what's needed
- Fast responses: 20KB instead of 2GB
- Better UX: Clients load data progressively
- Cost savings: Reduced bandwidth and compute
Pagination Patterns Comparison
| Pattern | Best For | Performance | Consistency | Complexity |
|---|---|---|---|---|
| Offset/Limit | Simple lists, admin panels | Poor at high offsets | Inconsistent (skips/duplicates) | Low |
| Cursor-Based | Infinite scroll, real-time feeds | Excellent | Consistent | Medium |
| Keyset | Large datasets, sorted results | Excellent | Consistent | Medium |
| Page Numbers | User-facing pagination with page jumps | Poor at high pages | Inconsistent | Low |
When to Use Each Pattern
Use Offset/Limit when:
- Dataset is small (<10K records)
- Admin panels where performance isn't critical
- Internal tools with controlled usage
- Example: Internal dashboard showing recent orders
Use Cursor-Based when:
- Infinite scroll interfaces (Twitter, Facebook feeds)
- Real-time data streams
- Large datasets with frequent changes
- Example: Twitter API, Slack API
Use Keyset when:
- Large datasets (millions of rows)
- Results are sorted by indexed column (created_at, id)
- You need consistent performance
- Example: GitHub API, Stripe API
Use Page Numbers when:
- Traditional pagination UI (1, 2, 3... buttons)
- Small-medium datasets (<100K records)
- Users need to jump to specific pages
- Example: Search results, product catalogs
Offset-Based Pagination
The simplest pattern. Uses limit (page size) and offset (how many to skip).
Request Format
GET /users?limit=20&offset=40
limit=20: Return 20 resultsoffset=40: Skip first 40 results (page 3)
Implementation
import { Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
interface PaginationQuery {
limit?: string;
offset?: string;
}
app.get('/users', async (req: Request<{}, {}, {}, PaginationQuery>, res: Response) => {
// Parse and validate pagination params
const limit = Math.min(parseInt(req.query.limit || '20'), 100); // Max 100
const offset = Math.max(parseInt(req.query.offset || '0'), 0); // Min 0
try {
// Parallel queries for data and count
const [users, total] = await Promise.all([
prisma.user.findMany({
skip: offset,
take: limit,
select: {
id: true,
name: true,
email: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
}),
prisma.user.count()
]);
res.json({
data: users,
pagination: {
offset,
limit,
total,
hasMore: offset + limit < total
}
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch users' });
}
});
SQL Generated
-- Data query
SELECT id, name, email, created_at
FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
-- Count query
SELECT COUNT(*) FROM users;
Pros
- Simple to implement: Two query parameters
- Intuitive: Easy to understand (skip 40, take 20)
- Jump to any page: Can calculate offset for page N
- Total count available: Know exactly how many results exist
Cons
- Poor performance at high offsets:
OFFSET 1000000is slow- Database must scan and discard 1M rows
- Linear time complexity: O(offset + limit)
- Inconsistent results: Duplicates or skipped items if data changes
- User deletes item #5 → all subsequent items shift
- Page 1: items 1-20, then item #10 gets deleted
- Page 2: Expected items 21-40, actually gets items 20-39 (duplicate item #20)
- Expensive count queries:
COUNT(*)on large tables is slow - Not recommended for >100K records
Real-World Example: Admin Panel
// Admin panel pagination with offset/limit
app.get('/admin/orders', requireAdmin, async (req, res) => {
const page = parseInt(req.query.page || '1');
const limit = 50; // Fixed page size
const offset = (page - 1) * limit;
const [orders, total] = await Promise.all([
prisma.order.findMany({
skip: offset,
take: limit,
include: { user: true, items: true },
orderBy: { createdAt: 'desc' }
}),
prisma.order.count()
]);
const totalPages = Math.ceil(total / limit);
res.json({
data: orders,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
});
});
Cursor-Based Pagination
Uses an opaque cursor (pointer) to mark position in the result set. Most scalable pattern for large datasets.
Request Format
# First page
GET /users?limit=20
# Next page
GET /users?limit=20&cursor=eyJpZCI6MTIzNH0=
# Previous page (if supported)
GET /users?limit=20&cursor=eyJpZCI6MTIzNH0=&direction=prev
Implementation
interface CursorPaginationQuery {
limit?: string;
cursor?: string; // Base64-encoded JSON: {"id": 1234, "createdAt": "2026-03-10T..."}
direction?: 'next' | 'prev';
}
// Encode cursor (id + sort field)
function encodeCursor(id: number, createdAt: Date): string {
return Buffer.from(JSON.stringify({ id, createdAt })).toString('base64');
}
// Decode cursor
function decodeCursor(cursor: string): { id: number; createdAt: string } | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
} catch {
return null;
}
}
app.get('/users', async (req: Request<{}, {}, {}, CursorPaginationQuery>, res: Response) => {
const limit = Math.min(parseInt(req.query.limit || '20'), 100);
const cursorData = req.query.cursor ? decodeCursor(req.query.cursor) : null;
const direction = req.query.direction || 'next';
// Build WHERE clause based on cursor
const where = cursorData ? {
OR: [
// If sorting by createdAt DESC, get items BEFORE the cursor
{ createdAt: { lt: new Date(cursorData.createdAt) } },
// Handle items with same createdAt (use id as tiebreaker)
{
createdAt: new Date(cursorData.createdAt),
id: { lt: cursorData.id }
}
]
} : {};
const users = await prisma.user.findMany({
where,
take: limit + 1, // Fetch one extra to check if hasMore
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' } // Tiebreaker for identical timestamps
],
select: {
id: true,
name: true,
email: true,
createdAt: true
}
});
const hasMore = users.length > limit;
const data = hasMore ? users.slice(0, -1) : users;
// Generate next cursor from last item
const nextCursor = hasMore && data.length > 0
? encodeCursor(data[data.length - 1].id, data[data.length - 1].createdAt)
: null;
res.json({
data,
pagination: {
nextCursor,
hasMore,
limit
}
});
});
SQL Generated
-- First page (no cursor)
SELECT id, name, email, created_at
FROM users
ORDER BY created_at DESC, id DESC
LIMIT 21;
-- Next page (with cursor for id=1234, createdAt='2026-03-10 14:30:00')
SELECT id, name, email, created_at
FROM users
WHERE created_at < '2026-03-10 14:30:00'
OR (created_at = '2026-03-10 14:30:00' AND id < 1234)
ORDER BY created_at DESC, id DESC
LIMIT 21;
Pros
- Excellent performance: Uses indexed WHERE clause (no OFFSET scan)
- Consistent results: Immune to deletions/insertions
- Cursor points to exact position, doesn't shift
- No count query needed: More efficient
- Scales to millions of rows: Constant time complexity O(limit)
Cons
- Can't jump to arbitrary page: Only next/prev navigation
- No total count: Don't know how many results exist
- More complex to implement: Encoding/decoding cursors
- Cursor format must be stable: Schema changes break old cursors
Real-World Example: Twitter-Style Feed
// Infinite scroll feed pagination
app.get('/posts', async (req, res) => {
const limit = 20;
const cursor = req.query.cursor ? decodeCursor(req.query.cursor as string) : null;
const posts = await prisma.post.findMany({
where: cursor ? {
OR: [
{ createdAt: { lt: new Date(cursor.createdAt) } },
{
createdAt: new Date(cursor.createdAt),
id: { lt: cursor.id }
}
]
} : {},
take: limit + 1,
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
],
include: {
author: { select: { id: true, name: true, avatar: true } },
_count: { select: { likes: true, comments: true } }
}
});
const hasMore = posts.length > limit;
const data = hasMore ? posts.slice(0, -1) : posts;
const nextCursor = hasMore && data.length > 0
? encodeCursor(data[data.length - 1].id, data[data.length - 1].createdAt)
: null;
res.json({ data, nextCursor, hasMore });
});
Keyset Pagination
Similar to cursor-based, but uses the actual sort key (e.g., id, createdAt) instead of an opaque cursor. More transparent but less flexible.
Request Format
# First page
GET /users?limit=20&sort=createdAt:desc
# Next page (last item had id=1234, createdAt=2026-03-10T14:30:00Z)
GET /users?limit=20&sort=createdAt:desc&after_id=1234&after_created_at=2026-03-10T14:30:00Z
Implementation
interface KeysetPaginationQuery {
limit?: string;
after_id?: string;
after_created_at?: string;
sort?: 'createdAt:asc' | 'createdAt:desc';
}
app.get('/users', async (req: Request<{}, {}, {}, KeysetPaginationQuery>, res: Response) => {
const limit = Math.min(parseInt(req.query.limit || '20'), 100);
const afterId = req.query.after_id ? parseInt(req.query.after_id) : null;
const afterCreatedAt = req.query.after_created_at;
const sortDir = req.query.sort === 'createdAt:asc' ? 'asc' : 'desc';
const where = afterId && afterCreatedAt ? {
OR: [
{ createdAt: sortDir === 'desc'
? { lt: new Date(afterCreatedAt) }
: { gt: new Date(afterCreatedAt) }
},
{
createdAt: new Date(afterCreatedAt),
id: sortDir === 'desc' ? { lt: afterId } : { gt: afterId }
}
]
} : {};
const users = await prisma.user.findMany({
where,
take: limit + 1,
orderBy: [
{ createdAt: sortDir },
{ id: sortDir }
]
});
const hasMore = users.length > limit;
const data = hasMore ? users.slice(0, -1) : users;
res.json({
data,
pagination: {
hasMore,
nextKeys: hasMore && data.length > 0 ? {
after_id: data[data.length - 1].id,
after_created_at: data[data.length - 1].createdAt.toISOString()
} : null
}
});
});
Pros
- Same performance as cursor-based: Indexed WHERE clause
- Transparent keys: Easier to debug (human-readable)
- Consistent results: Immune to shifts
- RESTful: Keys are part of resource representation
Cons
- Exposes internal schema: Clients know about
idandcreatedAtfields - Less flexible: Can't easily change sort logic without breaking clients
- Multiple query params: More verbose than single cursor
Real-World Example: GitHub API
GitHub's REST API uses keyset pagination with since parameter:
# Get users created after id=1000
GET https://api.github.com/users?since=1000&per_page=30
Response includes Link header for next page:
Link: <https://api.github.com/users?since=1030&per_page=30>; rel="next"
Page Number Pagination
Traditional pagination with page numbers (1, 2, 3...). Simple for users but has performance issues at high page numbers.
Request Format
GET /products?page=1&per_page=20
GET /products?page=5&per_page=20
Implementation
interface PageNumberQuery {
page?: string;
per_page?: string;
}
app.get('/products', async (req: Request<{}, {}, {}, PageNumberQuery>, res: Response) => {
const page = Math.max(parseInt(req.query.page || '1'), 1);
const perPage = Math.min(parseInt(req.query.per_page || '20'), 100);
const offset = (page - 1) * perPage;
const [products, total] = await Promise.all([
prisma.product.findMany({
skip: offset,
take: perPage,
where: { active: true },
orderBy: { name: 'asc' }
}),
prisma.product.count({ where: { active: true } })
]);
const totalPages = Math.ceil(total / perPage);
res.json({
data: products,
pagination: {
page,
perPage,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
});
});
Pros
- User-friendly: Everyone understands page numbers
- Jump to any page: Direct navigation to page 5
- Total pages known: Show "Page 5 of 42"
- Simple implementation: Just math on offset
Cons
- Same performance issues as offset: Slow at high pages
- Inconsistent results: Items shift between pages
- Requires total count: Expensive on large tables
Response Format Standards
Minimal Format
{
"data": [...],
"nextCursor": "eyJpZCI6MTIzNH0="
}
Standard Format
{
"data": [...],
"pagination": {
"limit": 20,
"offset": 40,
"total": 1500,
"hasMore": true
}
}
GitHub-Style Link Headers
Link: <https://api.example.com/users?page=3>; rel="next",
<https://api.example.com/users?page=1>; rel="first",
<https://api.example.com/users?page=10>; rel="last"
// Generate Link header
function buildLinkHeader(baseUrl: string, page: number, totalPages: number): string {
const links: string[] = [];
if (page < totalPages) {
links.push(`<${baseUrl}?page=${page + 1}>; rel="next"`);
}
if (page > 1) {
links.push(`<${baseUrl}?page=${page - 1}>; rel="prev"`);
}
links.push(`<${baseUrl}?page=1>; rel="first"`);
links.push(`<${baseUrl}?page=${totalPages}>; rel="last"`);
return links.join(', ');
}
app.get('/users', async (req, res) => {
// ... pagination logic ...
res.set('Link', buildLinkHeader('/users', page, totalPages));
res.json({ data: users });
});
Stripe-Style Response
Stripe API includes both data and pagination metadata:
{
"object": "list",
"data": [...],
"has_more": true,
"url": "/v1/customers",
"next_page": "starting_after=cus_123"
}
Performance Optimization
1. Index Your Sort Columns
-- Composite index for cursor-based pagination
CREATE INDEX idx_users_created_id ON users(created_at DESC, id DESC);
-- Query uses index efficiently
SELECT * FROM users
WHERE created_at < '2026-03-10 14:30:00'
OR (created_at = '2026-03-10 14:30:00' AND id < 1234)
ORDER BY created_at DESC, id DESC
LIMIT 21;
Why it matters:
- Without index: Full table scan (slow)
- With index: Index seek (fast)
- 1000x faster on large tables
2. Avoid COUNT(*) When Possible
// ❌ Slow: Counts all rows every time
const total = await prisma.user.count();
// ✅ Fast: Use cursor pagination (no count needed)
const hasMore = users.length > limit;
// ✅ Alternative: Cache count, update on writes
const cachedTotal = await redis.get('users:count');
Why COUNT(*) is slow:
- Must scan entire table (or index)
- Not needed for infinite scroll UIs
- Becomes a bottleneck at 10M+ rows
3. Parallel Queries
// ❌ Sequential: 200ms + 150ms = 350ms total
const users = await prisma.user.findMany({ skip: offset, take: limit });
const total = await prisma.user.count();
// ✅ Parallel: max(200ms, 150ms) = 200ms total
const [users, total] = await Promise.all([
prisma.user.findMany({ skip: offset, take: limit }),
prisma.user.count()
]);
4. Limit Maximum Page Size
// ❌ Dangerous: Client can request 1M records
const limit = parseInt(req.query.limit || '20');
// ✅ Safe: Cap at 100 records
const limit = Math.min(parseInt(req.query.limit || '20'), 100);
5. Use Database-Level Pagination
// ✅ Database does pagination (efficient)
const users = await prisma.user.findMany({ skip: offset, take: limit });
// ❌ Application does pagination (loads everything into memory)
const allUsers = await prisma.user.findMany();
const users = allUsers.slice(offset, offset + limit);
6. Denormalize Counts
For frequently accessed counts, denormalize into a separate table:
-- Denormalized counts table
CREATE TABLE entity_counts (
entity_type VARCHAR(50) PRIMARY KEY,
count BIGINT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Update on writes (trigger or application logic)
INSERT INTO entity_counts (entity_type, count, updated_at)
VALUES ('users', (SELECT COUNT(*) FROM users), NOW())
ON CONFLICT (entity_type) DO UPDATE SET
count = EXCLUDED.count,
updated_at = NOW();
Then query is instant:
const total = await prisma.entityCounts.findUnique({
where: { entityType: 'users' }
}).then(row => row?.count || 0);
Client Implementation
JavaScript/TypeScript Client
// Cursor-based pagination client
async function fetchAllUsers(): Promise<User[]> {
const allUsers: User[] = [];
let cursor: string | null = null;
while (true) {
const params = new URLSearchParams({ limit: '100' });
if (cursor) params.set('cursor', cursor);
const response = await fetch(`/api/users?${params}`);
const { data, pagination } = await response.json();
allUsers.push(...data);
if (!pagination.hasMore) break;
cursor = pagination.nextCursor;
// Rate limiting: Don't hammer the API
await new Promise(resolve => setTimeout(resolve, 100));
}
return allUsers;
}
React Infinite Scroll Hook
import { useState, useEffect } from 'react';
interface UsePaginationResult<T> {
data: T[];
loading: boolean;
error: Error | null;
loadMore: () => void;
hasMore: boolean;
}
function useCursorPagination<T>(url: string): UsePaginationResult<T> {
const [data, setData] = useState<T[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ limit: '20' });
if (cursor) params.set('cursor', cursor);
const response = await fetch(`${url}?${params}`);
const result = await response.json();
setData(prev => [...prev, ...result.data]);
setCursor(result.pagination.nextCursor);
setHasMore(result.pagination.hasMore);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load'));
} finally {
setLoading(false);
}
};
// Load first page on mount
useEffect(() => {
loadMore();
}, []);
return { data, loading, error, loadMore, hasMore };
}
// Usage
function UserList() {
const { data, loading, loadMore, hasMore } = useCursorPagination<User>('/api/users');
return (
<div>
{data.map(user => <UserCard key={user.id} user={user} />)}
{hasMore && (
<button onClick={loadMore} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Common Mistakes
1. Using OFFSET for Large Datasets
// ❌ Slow: Scanning 1M rows to skip them
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000000;
-- Takes 5+ seconds on 10M row table
// ✅ Fast: Using WHERE clause with index
SELECT * FROM users
WHERE created_at < '2026-01-01 00:00:00'
ORDER BY created_at DESC
LIMIT 20;
-- Takes <50ms on 10M row table
2. Not Handling Empty Results
// ❌ Crashes on empty results
const nextCursor = encodeCursor(
data[data.length - 1].id, // Throws if data is empty
data[data.length - 1].createdAt
);
// ✅ Safe: Check before accessing
const nextCursor = data.length > 0 && hasMore
? encodeCursor(data[data.length - 1].id, data[data.length - 1].createdAt)
: null;
3. Inconsistent Sort Order
// ❌ Inconsistent: Items with same createdAt in random order
ORDER BY created_at DESC
// ✅ Consistent: Tiebreaker using unique id
ORDER BY created_at DESC, id DESC
Why it matters: Without a tiebreaker, items with identical created_at values return in random order, causing duplicates/skips.
4. Not Validating Pagination Parameters
// ❌ Dangerous: Allows negative offsets, huge limits
const offset = parseInt(req.query.offset);
const limit = parseInt(req.query.limit);
// ✅ Safe: Validate and clamp
const offset = Math.max(parseInt(req.query.offset || '0'), 0);
const limit = Math.min(Math.max(parseInt(req.query.limit || '20'), 1), 100);
5. Returning Total Count on Every Request
// ❌ Expensive: COUNT(*) on every request
const [data, total] = await Promise.all([
prisma.user.findMany({ skip: offset, take: limit }),
prisma.user.count() // Slow on 10M+ rows
]);
// ✅ Efficient: Only return count on first page
const total = offset === 0 ? await prisma.user.count() : undefined;
6. Exposing Raw Database IDs in Cursors
// ❌ Exposes internal IDs
const cursor = `${user.id}`;
// ✅ Opaque cursor (Base64-encoded)
const cursor = Buffer.from(JSON.stringify({ id: user.id })).toString('base64');
7. Not Testing with Real Data Volumes
// ❌ Only test with 100 rows
// Works fine in development
// ✅ Test with production-like data
// Seed 10M rows, test offset=1000000
// Discover performance issues before users do
Production Checklist
Database:
- Composite index on sort columns (e.g.,
created_at, id) - Verify
EXPLAIN ANALYZEshows index usage (not seq scan) - Test performance with production data volume (10M+ rows)
- Monitor slow query logs for pagination queries
API Design:
- Choose pagination pattern based on use case
- Set maximum page size (recommend 100)
- Validate and sanitize all pagination parameters
- Return consistent response format
- Include
hasMoreornextCursorin response - Document pagination in API reference
Performance:
- Avoid
COUNT(*)unless necessary (use cursor pagination) - Parallel queries for data + count (if count needed)
- Cache total counts for frequently accessed endpoints
- Set database query timeout (prevent runaway queries)
- Monitor P95/P99 latency for pagination endpoints
Error Handling:
- Handle invalid cursor gracefully (return 400)
- Handle negative offsets/limits (clamp to valid range)
- Return empty array for out-of-range pages (not 404)
- Include error details in response
Testing:
- Test first page, middle page, last page
- Test empty results (no data)
- Test edge cases (limit=0, offset > total)
- Load test with realistic traffic patterns
- Test with concurrent writes (verify consistency)
Client Experience:
- Provide example client code in documentation
- Include pagination metadata in responses
- Use Link headers for discoverability (optional)
- Consistent field naming (
limitvsper_page)
Real-World Examples
GitHub API (Keyset Pagination)
GitHub uses since parameter for user listing:
curl https://api.github.com/users?since=1000&per_page=30
Response includes Link header:
Link: <https://api.github.com/users?since=1030&per_page=30>; rel="next",
<https://api.github.com/users?since=0&per_page=30>; rel="first"
Stripe API (Cursor-Based)
Stripe uses starting_after cursor:
curl https://api.stripe.com/v1/customers?limit=10&starting_after=cus_123
Response:
{
"object": "list",
"data": [...],
"has_more": true,
"url": "/v1/customers"
}
Twitter API (Cursor-Based)
Twitter API uses pagination_token:
curl "https://api.twitter.com/2/tweets/search/recent?query=api&max_results=10&pagination_token=b26v89c19zqg8o3fosga3t90"
Slack API (Cursor-Based)
Slack uses cursor for conversations:
curl "https://slack.com/api/conversations.history?channel=C1234&cursor=dXNlcjpVMEc5V0ZYTlo="
Related API Patterns
- API Error Handling Best Practices - Handle pagination errors gracefully
- API Rate Limiting - Prevent abuse of pagination endpoints
- API Performance Optimization - Monitor pagination query performance with Datadog
- Database Query Optimization - Optimize pagination queries in AWS RDS
- API Caching Strategies - Cache paginated responses with Cloudflare
Conclusion
Pagination is essential for production APIs. Choose the right pattern for your use case:
- Small datasets (<10K): Offset/limit or page numbers
- Large datasets (>100K): Cursor-based or keyset
- Infinite scroll UIs: Cursor-based
- Traditional pagination UI: Page numbers or keyset
Always index your sort columns, validate pagination parameters, and test with production data volumes. Your database (and your users) will thank you.
Monitor your APIs with API Status Check. Track uptime and outages for 160+ third-party services including GitHub, Stripe, AWS, Cloudflare, and Datadog.
API Status Check
Stop checking API status pages manually
Get instant email alerts when OpenAI, Stripe, AWS, and 100+ APIs go down. Know before your users do.
Free dashboard available · 14-day trial on paid plans · Cancel anytime
Browse Free Dashboard →