On a 1,000,000-row table with an index on (created_at, id), OFFSET server time grew with the number of skipped rows: a median of 5.845 ms on page 1,000, 48.25 ms on page 10,000 and 243.1 ms on the last page, 50,000. Keyset stayed within 0.034–0.059 ms (medians) on the same pages and read 23 buffers at any depth. This is one machine, Docker on Windows and cached data: read it as the shape of the curve and an order of magnitude, not as a forecast for your server.
- Database
- PostgreSQL 17.11 (Docker, postgres:17-alpine)
- Data
- 1,000,000 synthetic rows, index on (created_at, id)
- Measurements
- 3 runs × 10 rounds: 300 variant measurements + 30 pings; 60 warm-up rows excluded
- Environment
- One machine, Docker Desktop (WSL2) on Windows, warm cache
Raw data and bench code are open · To the files ↓
Contents
Question and hypothesis
In the “Keyset pagination in SQL” recipe, a single EXPLAIN of page 25,001 shows why OFFSET is slow: the database walks through every skipped row. One data point is not enough to tell where “still fine” ends. This study answers a different question: how does the time to fetch one 20-row page depend on its number, for LIMIT … OFFSET and for keyset pagination on (created_at, id), on the same PostgreSQL 17 table?
offsetvariant:ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET $1, where$1 = (N − 1) × 20.keysetvariant: page 1 is the same query withoutOFFSET; after that,WHERE (created_at, id) < ($1, $2)with the sameORDER BYandLIMIT 20. The boundary is the last row of the previous page, computed once before measuring.- Depths: pages 1, 100, 1,000, 10,000 and 50,000. Page 50,000 is the last one: 999,980 rows come before it.
The fairness criteria were also fixed in advance: one table, one index, one connection and identical server settings; a warm-up that is excluded from results; a shuffled depth order and alternating variants; a check that both variants return the same 20 rows. The protocol, bench files and raw samples (raw.csv) are stored together; the full code that produces the same samples again is below.
Bench and method
Server
- Value
- PostgreSQL 17.11,
postgres:17-alpineimage, container--cpus=2 --memory=2g
Settings
- Value
shared_buffers128MB,work_mem4MB,random_page_cost4,max_parallel_workers_per_gather2 — defaults;jit=offis the only change
Client
- Value
- Node.js 24.18.0,
pg8.23.1, one connection, parameters via$n
Machine
- Value
- Intel Core i7-14700 (20 cores, 28 threads), 63.7 GB RAM, Windows 11 Pro 10.0.26200, Docker 29.6.2 (Docker Desktop, WSL2)
Data
- Value
- 1,000,000 orders, 65 MB table, 30 MB
(created_at, id)index; generated withsetseed(0.42), identical checksum in every run
The schema and time distribution match the recipe: three orders per minute, and id order does not follow created_at order. Rows that are neighbours in the index live on different table pages, which is what happens when records are imported in batches. jit=off is a safeguard: JIT compilation is triggered by the estimated query cost and would add its own time. The highest estimate in this bench’s plans is 63,748, below the default jit_above_cost threshold (100,000), so JIT would not have kicked in with default settings either.
Fresh container and data
Each run starts with a new container:
setup.sqlcreates the table and index, runsVACUUM (ANALYZE)and prints a checksum.Boundaries and reference
Before measuring, the script finds the keyset boundary rows and stores the reference
ids of each page. This is not part of the measurement.Warm-up
Each of the ten queries (2 variants × 5 depths) runs twice. These rows stay in
raw.csvwithphase=warmupbut are excluded from the summary.10 rounds
Each round starts with
SELECT 1to estimate connection overhead. Depth order is shuffled by a fixed-seed generator, and variant order alternates between rounds.Two metrics per sample
First
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON)for server time and buffers, then the same query withoutEXPLAIN, timed in Node.js aroundclient.query()and checked against the reference rows.Three runs
The cycle was repeated on three fresh containers: 30 samples per point. The summary reports the median, p25/p75 and min/max.
TIMING OFF is deliberate. The EXPLAIN documentation warns that per-node timing can slow a query down noticeably on some systems, while total execution time is measured anyway. That matters for OFFSET, where up to a million rows pass through a node. Server time does not include sending the result to the client, as the EXPLAIN ANALYZE section states. Client time, on the other hand, includes the hop between Windows and the Docker VM, the driver and parsing 20 rows.
Results
243.1 ms243.1 ms
median OFFSET server time on page 50,000 (999,980 rows skipped)
0.059 ms0.059 ms
median keyset time on the same page
2323
buffers read by keyset at any depth; OFFSET on the last page reads 1,007,665
1
- Rows skipped
- 0
- OFFSET
- 0.037 [0.033–0.043]
- keyset
- 0.042 [0.036–0.049]
- OFFSET buffers
- 23
- keyset buffers
- 23
100
- Rows skipped
- 1,980
- OFFSET
- 0.660 [0.587–0.986]
- keyset
- 0.034 [0.029–0.040]
- OFFSET buffers
- 2,019
- keyset buffers
- 23
1,000
- Rows skipped
- 19,980
- OFFSET
- 5.845 [5.414–7.148]
- keyset
- 0.038 [0.033–0.049]
- OFFSET buffers
- 20,157
- keyset buffers
- 23
10,000
- Rows skipped
- 199,980
- OFFSET
- 48.25 [42.78–71.58] (40.95–111.5)
- keyset
- 0.044 [0.038–0.055]
- OFFSET buffers
- 201,535
- keyset buffers
- 23
50,000
- Rows skipped
- 999,980
- OFFSET
- 243.1 [215.5–281.4] (200.5–503.3)
- keyset
- 0.059 [0.040–0.066]
- OFFSET buffers
- 1,007,665
- keyset buffers
- 23
1
- OFFSET
- 0.486 [0.415–0.587]
- keyset
- 0.448 [0.412–0.559]
100
- OFFSET
- 1.031 [0.961–1.402]
- keyset
- 0.518 [0.476–0.604]
1,000
- OFFSET
- 5.361 [4.990–6.814]
- keyset
- 0.530 [0.505–0.582]
10,000
- OFFSET
- 46.84 [43.20–58.75]
- keyset
- 0.506 [0.465–0.678]
50,000
- OFFSET
- 239.9 [211.9–307.9] (195.3–378.8)
- keyset
- 0.601 [0.537–0.681]
All 300 variant samples returned exactly the same 20 rows as the reference. SELECT 1 on the same connection took 0.427 ms at the median [0.342–0.504], which is the floor for client time on this bench. Absolute deep-OFFSET values moved noticeably between runs: the page 50,000 median was 222.4, 272.7 and 245.1 ms in runs 1, 2 and 3.
Interpretation
The hypothesis held in shape. Skipped rows grew 10× between pages 1,000 and 10,000, and the OFFSET median went from 5.845 to 48.25 ms; another 5× to page 50,000 brought it to 243.1 ms. Buffers grow even more evenly: 20,157 for 19,980 skipped rows, 201,535 for 199,980 and 1,007,665 for 999,980 — roughly one table-page access per row.
The plan shows why. Even on the last page the planner chose Index Scan Backward on (created_at, id): it walked the index from the end and visited the table for every row, only to discard it. In explain-r1-offset-p50000.txt the scan node returned 1,000,000 rows to deliver the last 20. The PostgreSQL documentation describes exactly this: rows skipped by OFFSET still have to be computed inside the server. The index removes the sort, not the walk.
Keyset works differently: the (created_at, id) < ($1, $2) condition ends up in Index Cond, and the B-tree descends straight to the boundary. That is why page 1 and page 50,000 both read 23 buffers. On the last page, runs 2 and 3 picked a different plan — Bitmap Heap Scan plus a 20-row sort instead of run 1’s Index Scan Backward. The ANALYZE documentation explains the difference: statistics come from a random sample, change slightly on every run and, in rare cases, change the plan. Time barely moved: the run medians were 0.031, 0.068 and 0.063 ms.
The client view is different. For keyset, Node.js time (0.448–0.601 ms by median) is almost entirely connection overhead: the server-side part is 0.034–0.059 ms against 0.427 ms for SELECT 1. For OFFSET on page 1,000 and deeper, database work dominates and client medians are close to server ones. On page 100 the gap is 1.031 vs 0.518 ms on the client — invisible to a user for a single request.
Limitations
- One machine, Docker Desktop on Windows (WSL2). Other containers and the site’s dev server were running in parallel; background load was not stopped and shows up in the spread.
- Data fully in
shared_buffers: after warm-up there were no disk reads. A cold cache, a slow disk or tables larger than memory will produce different absolute numbers — this was not measured. - One connection, no concurrent load. This test does not show how either variant behaves with dozens of parallel queries.
- Synthetic distribution: table order does not match index order. A different distribution, row width or a covering index will change
OFFSET’s absolute numbers; the linear growth in rows walked does not go away — that is howOFFSETworks. - 30 samples per point from three runs give a median and a spread, not statistical significance. Deep-
OFFSETmedians differed noticeably between runs: from 222.4 to 272.7 ms on page 50,000. - Do not compare the numbers from the “Keyset pagination in SQL” recipe (a single
EXPLAINof page 25,001) directly with these tables: it uses a different measurement mode, with per-nodeTIMINGon, and different run conditions. The shape is comparable:OFFSETwalks the skipped rows, keyset does not. - Two debugging runs (script checks) came before the final cycle; their results are not published.
raw.csvholds every sample of the three final runs, unfiltered.
How to reproduce
You need Docker, Node.js 24 and a free port 55417. Put the four files below into one folder, next to a package.json with the "pg": "8.23.1" dependency and "type": "module". run.sh performs three runs, each on a fresh container, and prints the summary at the end:
$ cd paginaciya-offset-keyset-postgresql # bench folder
$ npm install # pg 8.23.1
$ bash run.sh # 3 runs
NOTICE: table "orders" does not exist, skipping
1000000 | 48c42fd798489f22b2847d4427ff3f80 | 65 MB | 30 MB
(1 row)
run 1: 2026-10-12T13:41:31Z
…
run 3: 2026-10-12T13:42:10Z
…
runs=1,2,3; rows measure=330, warmup=60; rows_match=0: 0docker run --rm -d --name kb-g-research --cpus=2 --memory=2g \
-e POSTGRES_PASSWORD=bench -e POSTGRES_DB=bench -p 55417:5432 \
postgres:17-alpine -c jit=off
docker exec kb-g-research pg_isready -U postgres -h 127.0.0.1
docker exec -i kb-g-research psql -U postgres -d bench -v ON_ERROR_STOP=1 < setup.sql
RUN=1 node bench.mjs > raw-1.csv
RUN=1 node bench.mjs --explain
node summarize.mjs raw-1.csv
docker stop kb-g-research-- R-01 bench: 1,000,000 synthetic orders, index on (created_at, id).
-- Run: docker exec -i kb-g-research psql -U postgres -d bench -v ON_ERROR_STOP=1 < setup.sql
-- Schema and created_at formula are the same as in the recept-sql-paginaciya recipe:
-- three orders per minute; id order does not match time order.
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_at timestamptz NOT NULL,
customer text NOT NULL,
total numeric(12, 2) NOT NULL
);
-- fixed seed: random() below returns the same values on every run
SELECT setseed(0.42);
INSERT INTO orders (created_at, customer, total)
SELECT timestamptz '2026-01-01 00:00:00+00' + ((g::bigint * 7919) % 333334) * interval '1 minute',
'customer-' || (floor(random() * 5000))::int,
round((100 + random() * 30000)::numeric, 2)
FROM generate_series(1, 1000000) AS g;
CREATE INDEX orders_created_at_id_idx ON orders (created_at, id);
VACUUM (ANALYZE) orders;
-- data checksum: identical when the data is generated again
SELECT count(*) AS rows,
md5(string_agg(id || '|' || created_at::text || '|' || customer || '|' || total, ',' ORDER BY id)) AS checksum,
pg_size_pretty(pg_relation_size('orders')) AS table_size,
pg_size_pretty(pg_relation_size('orders_created_at_id_idx')) AS index_size
FROM orders;/*
R-01 bench: OFFSET vs keyset in PostgreSQL 17 — response time by page depth.
npm install # pg from package.json
RUN=1 node bench.mjs > raw-1.csv # one run of measurements (warm-up + 10 rounds)
RUN=1 node bench.mjs --explain # explain-r1-*.txt for p10000 and p50000
node bench.mjs --settings # server version and settings (for env.txt)
The full cycle of three runs on fresh containers is run.sh.
Default DATABASE_URL: postgres://postgres:bench@localhost:55417/bench
*/
import { writeFileSync } from "node:fs";
import { performance } from "node:perf_hooks";
import pg from "pg";
const DB_URL = process.env.DATABASE_URL ?? "postgres://postgres:bench@localhost:55417/bench";
const PAGE = 20;
const DEPTHS = [1, 100, 1000, 10000, 50000];
const ROUNDS = Number(process.env.ROUNDS ?? 10);
const WARMUP = 2;
const SEED = 20261012;
const RUN = Number(process.env.RUN ?? 1);
// timestamptz (OID 1184) as a string: Date drops microseconds and the cursor boundary would shift
const types = { getTypeParser: (oid, f) => (oid === 1184 ? (v) => v : pg.types.getTypeParser(oid, f)) };
const COLS = "id, created_at, customer, total";
const ORDER = "ORDER BY created_at DESC, id DESC";
const SQL = {
offset: `SELECT ${COLS} FROM orders ${ORDER} LIMIT ${PAGE} OFFSET $1`,
first: `SELECT ${COLS} FROM orders ${ORDER} LIMIT ${PAGE}`,
keyset: `SELECT ${COLS} FROM orders WHERE (created_at, id) < ($1::timestamptz, $2::bigint) ${ORDER} LIMIT ${PAGE}`,
};
// query and parameters for a variant and depth; the keyset boundary is computed in advance
function query(variant, page, cursors) {
if (variant === "offset") return [SQL.offset, [(page - 1) * PAGE]];
if (page === 1) return [SQL.first, []];
const c = cursors.get(page);
return [SQL.keyset, [c.created_at, c.id]];
}
// deterministic PRNG for shuffling the depth order (each run has its own seed)
function mulberry32(a) {
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffled(arr, rnd) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function scanNode(plan) {
if (/Scan/.test(plan["Node Type"])) return [plan["Node Type"], plan["Scan Direction"], plan["Index Name"]].filter(Boolean).join(" ");
for (const p of plan.Plans ?? []) {
const s = scanNode(p);
if (s) return s;
}
return "";
}
const client = new pg.Client({ connectionString: DB_URL, types });
await client.connect();
// keyset boundary: the last row of page N−1; reference ids of page N (via OFFSET)
const cursors = new Map();
const reference = new Map();
for (const page of DEPTHS) {
if (page > 1) {
const { rows } = await client.query(`SELECT created_at, id FROM orders ${ORDER} LIMIT 1 OFFSET $1`, [(page - 1) * PAGE - 1]);
cursors.set(page, rows[0]);
}
const { rows } = await client.query(SQL.offset, [(page - 1) * PAGE]);
reference.set(page, rows.map((r) => r.id).join(","));
}
if (process.argv.includes("--settings")) {
const out = [];
out.push((await client.query("SELECT version()")).rows[0].version);
for (const s of ["shared_buffers", "work_mem", "effective_cache_size", "random_page_cost", "jit", "max_parallel_workers_per_gather", "max_worker_processes", "TimeZone"]) {
out.push(`${s} = ${Object.values((await client.query(`SHOW ${s}`)).rows[0])[0]}`);
}
const size = await client.query(`SELECT count(*) AS n, pg_size_pretty(pg_relation_size('orders')) AS t, pg_size_pretty(pg_relation_size('orders_created_at_id_idx')) AS i FROM orders`);
out.push(`orders: ${size.rows[0].n} rows, table ${size.rows[0].t}, index (created_at, id) ${size.rows[0].i}`);
for (const [p, c] of cursors) out.push(`keyset cursor for page ${p}: (${c.created_at}, ${c.id})`);
console.log(out.join("\n"));
await client.end();
process.exit(0);
}
if (process.argv.includes("--explain")) {
for (const page of [10000, 50000]) {
for (const variant of ["offset", "keyset"]) {
const [sql, params] = query(variant, page, cursors);
const { rows } = await client.query(`EXPLAIN (ANALYZE, BUFFERS) ${sql}`, params);
const text = [`-- ${variant}, page ${page} (page size ${PAGE})`, `-- ${sql}`, `-- params: ${JSON.stringify(params)}`, "", ...rows.map((r) => r["QUERY PLAN"])].join("\n");
const file = `explain-r${RUN}-${variant}-p${page}.txt`;
writeFileSync(new URL(file, import.meta.url), `-- run ${RUN}\n${text}\n`);
console.error(file);
}
}
await client.end();
process.exit(0);
}
async function measure(variant, page) {
const [sql, params] = query(variant, page, cursors);
const ex = await client.query(`EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) ${sql}`, params);
const plan = ex.rows[0]["QUERY PLAN"][0];
const t0 = performance.now();
const res = await client.query(sql, params);
const clientMs = performance.now() - t0;
return {
server_exec_ms: plan["Execution Time"],
planning_ms: plan["Planning Time"],
shared_hit: plan.Plan["Shared Hit Blocks"],
shared_read: plan.Plan["Shared Read Blocks"],
client_ms: clientMs,
rows: res.rows.length,
rows_match: res.rows.map((r) => r.id).join(",") === reference.get(page) ? 1 : 0,
scan_node: scanNode(plan.Plan),
};
}
async function ping() {
const t0 = performance.now();
await client.query("SELECT 1");
return performance.now() - t0;
}
const head = ["run", "phase", "round", "seq", "variant", "page", "offset_rows", "server_exec_ms", "planning_ms", "shared_hit", "shared_read", "client_ms", "rows", "rows_match", "scan_node"];
if (!process.env.NO_HEADER) console.log(head.join(","));
let seq = 0;
const emit = (phase, round, variant, page, m) =>
console.log([RUN, phase, round, ++seq, variant, page, page === 0 ? 0 : (page - 1) * PAGE,
m.server_exec_ms ?? "", m.planning_ms ?? "", m.shared_hit ?? "", m.shared_read ?? "",
m.client_ms.toFixed(3), m.rows ?? "", m.rows_match ?? "", m.scan_node ? `"${m.scan_node}"` : ""].join(","));
// warm-up: each query WARMUP times, excluded from medians
for (let w = 1; w <= WARMUP; w++) {
for (const page of DEPTHS) for (const variant of ["offset", "keyset"]) emit("warmup", w, variant, page, await measure(variant, page));
}
const rnd = mulberry32(SEED + RUN);
for (let round = 1; round <= ROUNDS; round++) {
emit("measure", round, "ping", 0, { client_ms: await ping() });
const variants = round % 2 === 0 ? ["offset", "keyset"] : ["keyset", "offset"];
for (const page of shuffled(DEPTHS, rnd)) {
for (const variant of variants) emit("measure", round, variant, page, await measure(variant, page));
}
}
await client.end();/*
Summary of raw.csv: node summarize.mjs raw.csv > summary.txt
phase=measure only. Percentiles use linear interpolation between neighbouring values (p25, p50 = median, p75).
*/
import { readFileSync } from "node:fs";
const [header, ...lines] = readFileSync(process.argv[2] ?? "raw.csv", "utf8").trim().split(/\r?\n/);
const keys = header.split(",");
const rows = lines.map((l) => Object.fromEntries(l.split(",").map((v, i) => [keys[i], v.replace(/^"|"$/g, "")])));
const m = rows.filter((r) => r.phase === "measure");
const q = (xs, p) => {
const s = [...xs].sort((a, b) => a - b);
const i = (s.length - 1) * p;
const lo = Math.floor(i);
return s[lo] + (s[Math.min(lo + 1, s.length - 1)] - s[lo]) * (i - lo);
};
const f = (x) => (x >= 100 ? x.toFixed(1) : x >= 10 ? x.toFixed(2) : x.toFixed(3));
const stat = (xs) => `${f(q(xs, 0.5))} [${f(q(xs, 0.25))}–${f(q(xs, 0.75))}] (${f(Math.min(...xs))}–${f(Math.max(...xs))})`;
const runs = [...new Set(m.map((r) => r.run))];
console.log(`runs=${runs.join(",")}; rows measure=${m.length}, warmup=${rows.length - m.length}; rows_match=0: ${m.filter((r) => r.rows_match === "0").length}`);
console.log("format: median [p25–p75] (min–max), ms; buffers: median (min–max)\n");
const pings = m.filter((r) => r.variant === "ping").map((r) => +r.client_ms);
console.log(`ping (SELECT 1), client_ms, n=${pings.length}: ${stat(pings)}\n`);
console.log("variant | page | offset_rows | n | server_exec_ms | client_ms | planning_ms | shared_hit | shared_read | scan_node");
for (const page of [...new Set(m.filter((r) => r.variant !== "ping").map((r) => +r.page))].sort((a, b) => a - b)) {
for (const variant of ["offset", "keyset"]) {
const g = m.filter((r) => r.variant === variant && +r.page === page);
if (!g.length) continue;
const hit = g.map((r) => +r.shared_hit);
const read = g.map((r) => +r.shared_read);
console.log([variant, page, g[0].offset_rows, g.length,
stat(g.map((r) => +r.server_exec_ms)), stat(g.map((r) => +r.client_ms)), stat(g.map((r) => +r.planning_ms)),
`${q(hit, 0.5)} (${Math.min(...hit)}–${Math.max(...hit)})`, `${q(read, 0.5)} (${Math.min(...read)}–${Math.max(...read)})`,
[...new Set(g.map((r) => r.scan_node))].join(" / ")].join(" | "));
}
}
console.log("\nmedians per run (each run: fresh container, fresh data generation), ms");
console.log("variant | page | " + runs.map((r) => `run ${r} server`).join(" | ") + " | " + runs.map((r) => `run ${r} client`).join(" | ") + " | scan_node per run");
for (const page of [...new Set(m.filter((r) => r.variant !== "ping").map((r) => +r.page))].sort((a, b) => a - b)) {
for (const variant of ["offset", "keyset"]) {
const by = (run) => m.filter((r) => r.variant === variant && +r.page === page && r.run === run);
console.log([variant, page,
...runs.map((run) => f(q(by(run).map((r) => +r.server_exec_ms), 0.5))),
...runs.map((run) => f(q(by(run).map((r) => +r.client_ms), 0.5))),
runs.map((run) => [...new Set(by(run).map((r) => r.scan_node))].join("+")).join(" / ")].join(" | "));
}
}#!/usr/bin/env bash
# R-01: three independent runs, each on a fresh container with freshly generated data.
# Run from the study folder (Linux, macOS or Git Bash on Windows): bash run.sh
# Requires Docker and Node.js; run npm install before the first run.
set -euo pipefail
cd "$(dirname "$0")"
IMAGE=postgres:17-alpine
NAME=kb-g-research
PORT=55417
RUNS=${RUNS:-3}
host_info() {
if command -v powershell.exe > /dev/null; then
powershell.exe -NoProfile -Command '[Console]::OutputEncoding = [Text.Encoding]::UTF8; $c = Get-CimInstance Win32_Processor; $s = Get-CimInstance Win32_ComputerSystem; $o = Get-CimInstance Win32_OperatingSystem; "CPU " + $c.Name + ", cores " + $c.NumberOfCores + ", logical " + $c.NumberOfLogicalProcessors; "RAM " + [math]::Round($s.TotalPhysicalMemory / 1GB, 1) + " GB"; $o.Caption + " " + $o.Version' | tr -d '\r'
elif command -v lscpu > /dev/null; then
lscpu | grep 'Model name'; free -g | head -2
else
sysctl -n machdep.cpu.brand_string hw.memsize
fi
}
: > raw.csv
for RUN in $(seq 1 "$RUNS"); do
docker run --rm -d --name "$NAME" --cpus=2 --memory=2g \
-e POSTGRES_PASSWORD=bench -e POSTGRES_DB=bench -p "$PORT:5432" \
"$IMAGE" -c jit=off > /dev/null
# wait for the final server (the temporary init server listens on the socket only)
for _ in $(seq 1 60); do
docker exec "$NAME" pg_isready -U postgres -h 127.0.0.1 > /dev/null 2>&1 && break
sleep 1
done
docker exec -i "$NAME" psql -U postgres -d bench -v ON_ERROR_STOP=1 < setup.sql | tail -n 3
echo "run $RUN: $(date -u +%FT%TZ)" >&2
if [ "$RUN" -eq 1 ]; then RUN=$RUN node bench.mjs >> raw.csv; else RUN=$RUN NO_HEADER=1 node bench.mjs >> raw.csv; fi
RUN=$RUN node bench.mjs --explain
if [ "$RUN" -eq 1 ]; then
{
echo "# R-01 environment, recorded $(date -u +%FT%TZ)"
echo "## host"
uname -a
host_info
echo "## runtime"
echo "node $(node -v)"
echo "pg $(node -p "require('./node_modules/pg/package.json').version")"
docker version --format 'docker client {{.Client.Version}}, server {{.Server.Version}} ({{.Server.Os}}/{{.Server.Arch}})'
docker info --format 'docker VM: {{.OperatingSystem}}, kernel {{.KernelVersion}}, {{.NCPU}} CPU, {{.MemTotal}} bytes'
docker image inspect "$IMAGE" --format 'image {{index .RepoTags 0}} {{.Id}}'
echo "container limits: --cpus=2 --memory=2g; server flag: -c jit=off"
echo "## postgres"
node bench.mjs --settings
} > env.txt
fi
docker stop "$NAME" > /dev/null
done
node summarize.mjs raw.csv > summary.txt
cat summary.txtbench.mjs --explain saves text EXPLAIN (ANALYZE, BUFFERS) plans for pages 10,000 and 50,000; --settings prints the server version, settings and cursor boundaries. If the setup.sql checksum matches 48c42fd798489f22b2847d4427ff3f80, you have the same data as this test and can compare buffer counts one to one.
Every study file — data, bench code, the dependency lock file and 12 EXPLAIN plans — is listed with sizes under “Study files” at the end of the article, next to the citation text.
What this means for a product
If users page through a catalogue or a list of requests a few pages deep, OFFSET is not your bottleneck: on page 100 it cost 0.660 ms of server time. Rewriting that pagination for speed is not worth it — page numbers and “go to page 7” are more convenient than a cursor.
Trouble starts where people go deep: infinite feeds, exporting a whole table page by page for an integration or a report, public lists that crawlers and bots walk with ?page=40000. Every next OFFSET page costs more than the previous one, and a single request for the last page took hundreds of milliseconds on this bench. On a slow disk, a bigger table or under load the number will be different, and it is worth measuring on your own setup.
Keep OFFSET
- Numbered pages with limited depth (say, the first few hundred pages)
- Small tables and rarely changing data
- Admin screens where jumping to an arbitrary page matters
Move to keyset
- Feeds and infinite scroll
- Exporting a whole table in batches in the background or for an integration
- Public lists where deep pages are open to anyone
Next step
How to cite
Cite with the URL and publication date. Quote the numbers together with their conditions: one machine, Docker on Windows, warm cache. Found a mistake in the method or data? Write to hello@interlab.app.
InterLAB Research (2026). R-01: OFFSET vs keyset pagination in PostgreSQL 17: how response time depends on page depth. Published 12 October 2026. https://interlab.app/en/blog/paginaciya-offset-keyset-postgresql Raw data and bench code: https://interlab.app/research/paginaciya-offset-keyset-postgresql/raw.csv
Study files
- raw.csvall measurements: 390 rows (300 variant measurements, 30 pings, 60 warm-up)39 KB
- summary.txtsummary: median, p25/p75, min/max per point and per run4.8 KB
- env.txtversions, server settings, data checksum1.5 KB
- README.mdprotocol, deviations and commands to reproduce12 KB
- setup.sqlgeneration of 1,000,000 rows with a fixed seed1.6 KB
- bench.mjsmeasurement script7.3 KB
- summarize.mjssummary calculation from raw.csv3.3 KB
- run.shfull three-run cycle2.8 KB
- package.jsonpg 8.23.1 dependency0.2 KB
- package-lock.jsonlocked dependency versions5.6 KB
EXPLAIN (ANALYZE, BUFFERS) plans
one execution per run, variant and depth — for reading the plan, not for the timing table
r1-offset-p10000r1-offset-p50000r1-keyset-p10000r1-keyset-p50000r2-offset-p10000r2-offset-p50000r2-keyset-p10000r2-keyset-p50000r3-offset-p10000r3-offset-p50000r3-keyset-p10000r3-keyset-p50000
