export interface PageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
}
export interface Edge<T> {
node: T;
cursor: string;
}
export interface Connection<T> {
edges: Edge<T>[];
pageInfo: PageInfo;
}
export interface Keyset {
createdAt: string;
id: string;
}
export function encodeCursor(key: Keyset): string {
return Buffer.from(JSON.stringify(key), "utf8").toString("base64url");
}
export function decodeCursor(cursor: string): Keyset {
const raw = Buffer.from(cursor, "base64url").toString("utf8");
const parsed = JSON.parse(raw) as Partial<Keyset>;
if (!parsed.createdAt || !parsed.id) {
throw new Error("malformed cursor");
}
return { createdAt: parsed.createdAt, id: parsed.id };
}
import { Connection, Edge, Keyset, encodeCursor } from "./contract";
export interface PaginationQuery {
first: number;
after: Keyset | null;
}
export function buildConnection<T>(
rows: T[],
query: PaginationQuery,
toKeyset: (row: T) => Keyset
): Connection<T> {
const hasNextPage = rows.length > query.first;
const page = hasNextPage ? rows.slice(0, query.first) : rows;
const edges: Edge<T>[] = page.map((node) => ({
node,
cursor: encodeCursor(toKeyset(node)),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: query.after !== null,
startCursor: edges.length ? edges[0].cursor : null,
endCursor: edges.length ? edges[edges.length - 1].cursor : null,
},
};
}
import { Pool } from "pg";
import { Keyset } from "./contract";
export interface Article {
id: string;
title: string;
createdAt: string;
}
export class ArticleRepository {
constructor(private readonly pool: Pool) {}
async fetchPage(limit: number, after: Keyset | null): Promise<Article[]> {
const params: unknown[] = [];
let where = "";
if (after) {
params.push(after.createdAt, after.id);
where = "WHERE (created_at, id) < ($1, $2)";
}
params.push(limit);
const { rows } = await this.pool.query(
`SELECT id, title, created_at AS "createdAt"
FROM articles
${where}
ORDER BY created_at DESC, id DESC
LIMIT $${params.length}`,
params
);
return rows as Article[];
}
}
import { Router, Request, Response } from "express";
import { ArticleRepository, Article } from "./repository";
import { buildConnection } from "./buildConnection";
import { decodeCursor, Keyset } from "./contract";
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
export function articlesRouter(repo: ArticleRepository): Router {
const router = Router();
router.get("/articles", async (req: Request, res: Response) => {
const requested = Number.parseInt(String(req.query.first ?? ""), 10);
const first = Number.isFinite(requested)
? Math.min(Math.max(requested, 1), MAX_PAGE_SIZE)
: DEFAULT_PAGE_SIZE;
let after: Keyset | null = null;
if (typeof req.query.after === "string" && req.query.after.length) {
try {
after = decodeCursor(req.query.after);
} catch {
return res.status(400).json({ error: "invalid cursor" });
}
}
const rows = await repo.fetchPage(first + 1, after);
const connection = buildConnection<Article>(rows, { first, after }, (a) => ({
createdAt: a.createdAt,
id: a.id,
}));
return res.json(connection);
});
return router;
}
Cursor-based pagination avoids the classic OFFSET problem: as the underlying table grows, skipping thousands of rows gets slower and, worse, drifting inserts and deletes can cause items to be repeated or silently skipped between pages. This snippet models a stable, typed pagination contract around keyset seeking instead, borrowing the Connection/PageInfo shape popularized by GraphQL Relay but exposing it over a plain REST endpoint.
The pagination contract tab defines the wire shape. A Connection<T> carries edges (each edge pairing a node with its opaque cursor) and a PageInfo block describing hasNextPage, hasPreviousPage, and the boundary cursors. Cursors are deliberately opaque strings: encodeCursor and decodeCursor base64-encode the sort key so clients cannot depend on their internal structure, which keeps the encoding free to change later. The Keyset type captures exactly what a cursor points at — a createdAt timestamp plus an id tiebreaker — so equal timestamps still produce a total ordering.
The buildConnection helper tab is the generic glue. It accepts one extra row beyond the requested first, uses that overflow to compute hasNextPage without a second COUNT query, then trims the slice back to size. hasPreviousPage is inferred from whether an after cursor was supplied. This is why PaginationQuery requests first items but the repository fetches first + 1: the sentinel row is a cheap way to answer "is there more?".
The articles repository tab shows the keyset SQL. When a decoded cursor exists, the WHERE (created_at, id) < ($1, $2) row-value comparison seeks past the last-seen key using the composite sort, and the index on (created_at DESC, id DESC) makes each page a fast range scan regardless of depth.
The articles route tab parses and clamps first to a sane maximum to prevent unbounded queries, decodes after, and hands the result to buildConnection. Two trade-offs are worth noting: cursor pagination cannot jump to an arbitrary page number, and cursors must stay valid across the chosen sort order, so changing the sort invalidates outstanding cursors. For infinite-scroll feeds and large tables, that is usually an easy price for consistent, index-friendly reads.
Related snips
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
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
Share this code
Here's the card — post it anywhere.