Build Stable Keyset Cursors for List APIs
Encode keyset cursors as opaque base64 JSON and page records by creation time and ID without offset drift.
Code
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"time"
)
type Cursor struct {
CreatedAt time.Time
ID int
}
type Item struct {
ID int
CreatedAt time.Time
Name string
}
func encodeCursor(cursor Cursor) string {
payload, err := json.Marshal(cursor)
if err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func decodeCursor(raw string) (Cursor, error) {
var cursor Cursor
payload, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return cursor, fmt.Errorf("decode cursor: %w", err)
}
if err := json.Unmarshal(payload, &cursor); err != nil {
return cursor, fmt.Errorf("parse cursor: %w", err)
}
if cursor.ID <= 0 || cursor.CreatedAt.IsZero() {
return cursor, fmt.Errorf("cursor is missing fields")
}
return cursor, nil
}
func afterCursor(item Item, cursor Cursor) bool {
if item.CreatedAt.After(cursor.CreatedAt) {
return true
}
return item.CreatedAt.Equal(cursor.CreatedAt) && item.ID > cursor.ID
}
func pageAfter(items []Item, cursor *Cursor, limit int) []Item {
if limit <= 0 {
return nil
}
ordered := append([]Item(nil), items...)
sort.Slice(ordered, func(left, right int) bool {
if ordered[left].CreatedAt.Equal(ordered[right].CreatedAt) {
return ordered[left].ID < ordered[right].ID
}
return ordered[left].CreatedAt.Before(ordered[right].CreatedAt)
})
page := make([]Item, 0, limit)
for _, item := range ordered {
if cursor != nil && !afterCursor(item, *cursor) {
continue
}
page = append(page, item)
if len(page) == limit {
break
}
}
return page
}
func main() {
base := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
items := []Item{
{ID: 1, CreatedAt: base.Add(1 * time.Minute), Name: "alpha"},
{ID: 2, CreatedAt: base.Add(2 * time.Minute), Name: "beta"},
{ID: 3, CreatedAt: base.Add(3 * time.Minute), Name: "gamma"},
{ID: 4, CreatedAt: base.Add(4 * time.Minute), Name: "delta"},
}
first := pageAfter(items, nil, 2)
encoded := encodeCursor(Cursor{CreatedAt: first[1].CreatedAt, ID: first[1].ID})
cursor, err := decodeCursor(encoded)
if err != nil {
panic(err)
}
second := pageAfter(items, &cursor, 2)
fmt.Println(encoded)
fmt.Println(first[0].ID, first[1].ID, second[0].ID, second[1].ID)
if len(first) != 2 || len(second) != 2 || first[0].ID != 1 || second[0].ID != 3 {
panic("unexpected page contents")
}
if _, err := decodeCursor("not-base64!"); err == nil {
panic("invalid cursor should fail")
}
}
Report a problem with this exampleOpens a pre-filled issue on GitHub.