June 23, 2026 8 min read

Cursor-Based Pagination for Payment Transaction APIs

Offset pagination works fine until your transaction table has 50 million rows and new records land every second. Here's why every payment API I've built in the last three years uses cursor-based pagination — and how to implement it without the pitfalls.

Why Offset Pagination Fails for Payments

The classic ?page=3&limit=25 pattern has two fatal problems in payment systems:

I learned this the hard way when a merchant dashboard started timing out on their transaction history. They had 2M+ transactions, and customers scrolling to older records were hitting 10-second queries. The fix wasn't an index — it was rethinking the pagination model entirely.

The numbers: On a 10M-row transactions table, OFFSET 5000000 LIMIT 25 takes ~4.2s. The equivalent cursor query takes ~2ms. That's a 2,000x improvement.

Cursor Pagination — The Core Idea

Instead of saying "skip N rows," you say "give me rows after this specific point." The cursor encodes the last item's position, so the database can seek directly to it using an index.

Client Request
cursor=abc123
Decode Cursor
ts + id
WHERE (ts, id) < ($1, $2)
Index Seek
O(log n)

The key insight: cursors leverage B-tree indexes. The database doesn't scan from the beginning — it jumps directly to the cursor position and reads forward. Constant time regardless of how deep into the dataset you are.

Designing the Cursor

A good cursor for payment transactions needs two components:

Using timestamp alone fails because batch processing can create hundreds of transactions with identical timestamps. The compound cursor (created_at, transaction_id) guarantees deterministic ordering.

// Cursor structure
type Cursor struct {
    Timestamp time.Time
    ID        string
}

// Encode to opaque base64 string
func (c Cursor) Encode() string {
    raw := fmt.Sprintf("%d:%s", c.Timestamp.UnixMicro(), c.ID)
    return base64.URLEncoding.EncodeToString([]byte(raw))
}

// Decode from client-provided string
func DecodeCursor(s string) (Cursor, error) {
    raw, err := base64.URLEncoding.DecodeString(s)
    if err != nil {
        return Cursor{}, ErrInvalidCursor
    }
    parts := strings.SplitN(string(raw), ":", 2)
    if len(parts) != 2 {
        return Cursor{}, ErrInvalidCursor
    }
    ts, err := strconv.ParseInt(parts[0], 10, 64)
    if err != nil {
        return Cursor{}, ErrInvalidCursor
    }
    return Cursor{
        Timestamp: time.UnixMicro(ts),
        ID:        parts[1],
    }, nil
}

Why base64? Making cursors opaque prevents clients from constructing them manually or depending on internal structure. You can change the encoding later without breaking clients.

Go Implementation

Here's the query pattern that makes cursor pagination fast:

func (r *TxnRepo) ListTransactions(ctx context.Context,
    merchantID string, cursor *Cursor, limit int,
) ([]Transaction, *Cursor, error) {
    query := `
        SELECT id, merchant_id, amount, currency, status, created_at
        FROM transactions
        WHERE merchant_id = $1
    `
    args := []any{merchantID}

    if cursor != nil {
        query += ` AND (created_at, id) < ($2, $3)`
        args = append(args, cursor.Timestamp, cursor.ID)
    }

    query += ` ORDER BY created_at DESC, id DESC LIMIT $` +
        strconv.Itoa(len(args)+1)
    args = append(args, limit+1) // fetch one extra to detect next page

    rows, err := r.db.QueryContext(ctx, query, args...)
    if err != nil {
        return nil, nil, fmt.Errorf("list transactions: %w", err)
    }
    defer rows.Close()

    var txns []Transaction
    for rows.Next() {
        var t Transaction
        if err := rows.Scan(&t.ID, &t.MerchantID, &t.Amount,
            &t.Currency, &t.Status, &t.CreatedAt); err != nil {
            return nil, nil, err
        }
        txns = append(txns, t)
    }

    var nextCursor *Cursor
    if len(txns) > limit {
        last := txns[limit-1]
        nextCursor = &Cursor{Timestamp: last.CreatedAt, ID: last.ID}
        txns = txns[:limit]
    }
    return txns, nextCursor, nil
}

The LIMIT N+1 trick avoids a separate count query — if you get more rows than requested, there's a next page.

Handling Real-Time Inserts

Payment tables grow constantly. The beauty of cursor pagination: new transactions inserted after the cursor position don't affect results. The client always sees a consistent slice moving backward through time.

But there's an edge case: what about transactions that arrive out of order? Late-settling batch transactions might have a created_at in the past. Two strategies:

Comparison: Offset vs Cursor vs Keyset

Aspect Offset Cursor (opaque) Keyset (visible)
Deep page performance O(offset + limit) O(log n) O(log n)
Real-time consistency Duplicates/gaps Stable Stable
Jump to arbitrary page Yes No No
Client coupling Low None (opaque) High (exposed fields)
Best for Small datasets, admin UIs APIs, infinite scroll Internal services

API Response Format

A clean response envelope for cursor-paginated payment APIs:

{
  "data": [
    {
      "id": "txn_8kx92mf",
      "amount": 4500,
      "currency": "SGD",
      "status": "settled",
      "created_at": "2026-06-22T14:30:00Z"
    }
  ],
  "pagination": {
    "has_more": true,
    "next_cursor": "MTcxOTMzMDIwMDAwMDAwMDp0eG5fOGt4OTJtZg==",
    "limit": 25
  }
}

Key design decisions: has_more is explicit (don't make clients guess), the cursor is a single opaque string, and limit is echoed back so clients know what they asked for.

Production Lessons

  1. Index must match sort order exactly. For ORDER BY created_at DESC, id DESC, you need a composite index on (merchant_id, created_at DESC, id DESC). A mismatch means a sort operation instead of an index scan.
  2. Set a maximum page size. We cap at 100. Without a limit, a single API call could return 50K rows and OOM your service.
  3. Expire cursors gracefully. If a cursor references a partitioned-away partition, return a clear error with a message to restart from the beginning — not a 500.
  4. Don't expose total count. COUNT(*) on a 50M-row table is expensive. If the UI needs a count, cache it and update it asynchronously.
  5. Test with production-scale data. Cursor pagination that works on 1,000 rows in dev might hit edge cases at scale — especially around timestamp precision and tie-breaking.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.