Skip to content

Cursor Based

DodaTech 3 min read

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

  1. Why are cursors encoded (e.g., base64)?
  2. What makes a good cursor field?
  3. How does cursor pagination avoid phantom reads?
  4. Why can't cursor pagination easily provide total counts?
  5. What happens when a cursor references a deleted record?

Answers:

  1. To hide internal implementation details from clients.
  2. A unique, sequential field like created_at + id or a UUID.
  3. It uses database-level filters (WHERE id > cursor) instead of page positions.
  4. The total changes as data is added/removed, so it's expensive and inconsistent.
  5. 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

Is cursor pagination always better than offset?

: For real-time data, yes. For simple, static datasets, offset is simpler.

Can I use both cursor and offset?

: Yes. Offer cursor for stable navigation and offset for page jumping.

What is the Relay cursor format?

: Relay uses base64-encoded JSON with cursor fields in edges.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro