package feed
import (
"encoding/base64"
"encoding/json"
"time"
)
type Cursor struct {
CreatedAt time.Time `json:"c"`
ID int64 `json:"i"`
}
func (c Cursor) Encode() string {
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
func DecodeCursor(token string) (*Cursor, error) {
if token == "" {
return nil, nil
}
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return nil, err
}
var c Cursor
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
return &c, nil
}
package feed
import (
"context"
"database/sql"
"time"
)
type Post struct {
ID int64 `json:"id"`
Author string `json:"author"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
}
type Repository struct {
DB *sql.DB
}
func (r *Repository) ListPosts(ctx context.Context, after *Cursor, limit int) ([]Post, *Cursor, error) {
const base = `SELECT id, author, body, created_at FROM posts`
const order = ` ORDER BY created_at DESC, id DESC LIMIT $1`
var rows *sql.Rows
var err error
if after == nil {
rows, err = r.DB.QueryContext(ctx, base+order, limit+1)
} else {
rows, err = r.DB.QueryContext(ctx,
base+` WHERE (created_at, id) < ($2, $3)`+order,
limit+1, after.CreatedAt, after.ID)
}
if err != nil {
return nil, nil, err
}
defer rows.Close()
posts := make([]Post, 0, limit+1)
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID, &p.Author, &p.Body, &p.CreatedAt); err != nil {
return nil, nil, err
}
posts = append(posts, p)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
var next *Cursor
if len(posts) > limit {
posts = posts[:limit]
last := posts[len(posts)-1]
next = &Cursor{CreatedAt: last.CreatedAt, ID: last.ID}
}
return posts, next, nil
}
package feed
import (
"encoding/json"
"net/http"
"strconv"
)
const maxLimit = 100
type Handler struct {
Repo *Repository
}
type listResponse struct {
Posts []Post `json:"posts"`
NextCursor string `json:"next_cursor"`
}
func (h *Handler) ListPostsHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := 20
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
if limit > maxLimit {
limit = maxLimit
}
after, err := DecodeCursor(q.Get("cursor"))
if err != nil {
http.Error(w, "invalid cursor", http.StatusBadRequest)
return
}
posts, next, err := h.Repo.ListPosts(r.Context(), after, limit)
if err != nil {
http.Error(w, "failed to load feed", http.StatusInternalServerError)
return
}
resp := listResponse{Posts: posts}
if next != nil {
resp.NextCursor = next.Encode()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Keyset pagination (also called seek pagination) avoids the well-known problem with OFFSET: as a client walks deep into a result set, the database must still scan and discard every skipped row, so page 10,000 gets progressively slower and can even skip or duplicate rows when data changes underneath. Instead of an offset, keyset pagination remembers the sort position of the last row seen and asks for rows strictly after it. This snippet shows the full path from an opaque cursor token to a bounded SQL query.
In cursor.go, a page position is encoded as a Cursor holding the composite sort key (CreatedAt, ID). The ID tiebreaker matters: created_at is not unique, so ordering on it alone would make the boundary ambiguous and risk dropping rows that share a timestamp. Encode serializes the cursor to JSON and base64-url encodes it so it survives as a query-string token, while DecodeCursor reverses that and returns nil for an empty token, which the caller treats as "start from the beginning".
In repository.go, ListPosts builds the query around that key. The WHERE (created_at, id) < ($1, $2) row-value comparison is the heart of the technique: Postgres compares the tuple lexicographically, which exactly expresses "everything before this position" in the ORDER BY created_at DESC, id DESC ordering, and it maps directly onto a composite index on (created_at, id) for an index-only seek. The query fetches limit + 1 rows so the repository can tell whether a further page exists without a separate COUNT. If the extra row comes back, it is trimmed off and a next cursor is built from the last kept row.
In handler.go, ListPostsHandler parses limit and the opaque cursor param, clamps the limit to a sane maximum to stop clients requesting huge pages, and returns the rows alongside a next_cursor. An empty NextCursor signals the end of the feed. The main trade-off is that keyset pagination only supports next/previous traversal, not random jumps to arbitrary page numbers, and the cursor must be built from the same columns as the ORDER BY. For infinite-scroll feeds and large tables that constraint is a good deal.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.