ResearchOctober 12, 202610 min read

OFFSET vs keyset pagination in PostgreSQL 17: how response time depends on page depth

In short

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.

InterLAB research · R-01Measured: October 12, 2026
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
  1. 01Question and hypothesis
  2. 02Bench and method
  3. 03Results
  4. 04Interpretation
  5. 05Limitations
  6. 06How to reproduce
  7. 07What this means for a product
  8. 08Next step

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?

  • offset variant: ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET $1, where $1 = (N − 1) × 20.
  • keyset variant: page 1 is the same query without OFFSET; after that, WHERE (created_at, id) < ($1, $2) with the same ORDER BY and LIMIT 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-alpine image, container --cpus=2 --memory=2g

Settings

Value
shared_buffers 128MB, work_mem 4MB, random_page_cost 4, max_parallel_workers_per_gather 2 — defaults; jit=off is the only change

Client

Value
Node.js 24.18.0, pg 8.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 with setseed(0.42), identical checksum in every run
environment from env.txt; all settings come from SHOW on the running server

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.

  1. Fresh container and data

    Each run starts with a new container: setup.sql creates the table and index, runs VACUUM (ANALYZE) and prints a checksum.

  2. 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.

  3. Warm-up

    Each of the ten queries (2 variants × 5 depths) runs twice. These rows stay in raw.csv with phase=warmup but are excluded from the summary.

  4. 10 rounds

    Each round starts with SELECT 1 to estimate connection overhead. Depth order is shuffled by a fixed-seed generator, and variant order alternates between rounds.

  5. Two metrics per sample

    First EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) for server time and buffers, then the same query without EXPLAIN, timed in Node.js around client.query() and checked against the reference rows.

  6. 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 ms

median OFFSET server time on page 50,000 (999,980 rows skipped)

0.059 ms

median keyset time on the same page

23

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
server Execution Time, ms: median [p25–p75], plus (min–max) for the two deepest points; 30 samples per point, data cached (shared_read = 0 in every sample)

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]
client time in Node.js (performance.now() around client.query()), ms: median [p25–p75]

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 how OFFSET works.
  • 30 samples per point from three runs give a median and a spread, not statistical significance. Deep-OFFSET medians 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 EXPLAIN of page 25,001) directly with these tables: it uses a different measurement mode, with per-node TIMING on, and different run conditions. The shape is comparable: OFFSET walks the skipped rows, keyset does not.
  • Two debugging runs (script checks) came before the final cycle; their results are not published. raw.csv holds 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:

terminal
$ 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: 0
real output of the 12.10.2026 run, middle trimmed; the full summary matches the tables above
bash
docker 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
a single run by hand — what run.sh does in a loop; in PowerShell, replace the < setup.sql redirect with Get-Content setup.sql -Raw |
setup.sql
-- 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;
bench.mjs
/*
  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();
summarize.mjs
/*
  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(" | "));
  }
}
run.sh
#!/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.txt
on Windows, use Git Bash; the host_info function writes CPU, memory and OS details to env.txt

bench.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

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

All research and the publication protocol →
Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.