Cursor Based
title: "Cursor-Based Pagination — Stable Navigation for Real-Time Data" description: "Cursor-based pagination uses a unique cursor value to mark position in a result set, providing stable pages even when data is inserted or deleted." date: 2026-06-28 lastmod: 2026-06-28 weight: 14 tags: [apis, pagination] }
Cursor-based pagination uses an opaque cursor string to reference a position in the result set, ensuring stable pagination even when new items are added or existing items change.
What You'll Learn
- How cursor pagination works
- Encoding and decoding cursors
- When to choose cursor over offset
Why It Matters
Cursor pagination eliminates phantom reads and duplicate items, making it ideal for real-time feeds and frequently updated data.
Code Examples
# Cursor-based pagination
@app.route('/messages')
def list_messages():
cursor = request.args.get('cursor')
limit = min(request.args.get('limit', 20, type=int), 100)
if cursor:
# Decode cursor (base64 encoded timestamp+id)
cursor_data = json.loads(base64.b64decode(cursor))
messages = db.execute("""
SELECT * FROM messages
WHERE (created_at, id) < (%s, %s)
ORDER BY created_at DESC, id DESC
LIMIT %s
""", [cursor_data['created_at'], cursor_data['id'], limit + 1])
else:
messages = db.execute("""
SELECT * FROM messages
ORDER BY created_at DESC, id DESC
LIMIT %s
""", [limit + 1])
has_more = len(messages) > limit
if has_more:
messages = messages[:limit]
last = messages[-1]
next_cursor = base64.b64encode(json.dumps({
'created_at': last.created_at.isoformat(),
'id': last.id
}).encode()).decode()
else:
next_cursor = None
return jsonify({
"data": [m.to_dict() for m in messages],
"pagination": {
"next_cursor": next_cursor,
"has_more": has_more
}
})
// Cursor pagination in Node.js
app.get('/events', async (req, res) => {
const cursor = req.query.cursor;
const limit = Math.min(req.query.limit || 20, 100);
let query;
let params;
if (cursor) {
const decoded = JSON.parse(Buffer.from(cursor, 'base64'));
query = `SELECT * FROM events WHERE (id) < ($1) ORDER BY id DESC LIMIT $2`;
params = [decoded.id, limit + 1];
} else {
query = `SELECT * FROM events ORDER BY id DESC LIMIT $1`;
params = [limit + 1];
}
const results = await db.query(query, params);
const hasMore = results.rows.length > limit;
if (hasMore) results.rows.pop();
res.json({
data: results.rows,
nextCursor: hasMore
? Buffer.from(JSON.stringify({ id: results.rows[results.rows.length - 1].id })).toString('base64')
: null,
hasMore
});
});
Common Mistakes
1. Exposing Raw Database IDs as Cursors
Always encode cursors (base64) to hide implementation details.
2. Cursor Not Unique or Not Sortable
Cursors must reference unique, sequentially sortable values.
3. Forward-Only Pagination
Implement both forward and backward cursor navigation.
4. No Total Count
Cursor pagination doesn't easily provide total counts. Accept this tradeoff.
5. Cursor Expiration
Old cursors may reference deleted records. Handle gracefully.
Practice Questions
- Why are cursors encoded (e.g., base64)?
- What makes a good cursor field?
- How does cursor pagination avoid phantom reads?
- Why can't cursor pagination easily provide total counts?
- What happens when a cursor references a deleted record?
Answers:
- To hide internal implementation details from clients.
- A unique, sequential field like created_at + id or a UUID.
- It uses database-level filters (
WHERE id > cursor) instead of page positions. - The total changes as data is added/removed, so it's expensive and inconsistent.
- Return the next page starting from where valid records exist.
Challenge: Implement cursor-based pagination for a social media feed where new posts are constantly added. Show both forward and backward navigation.
FAQ
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro