API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較
管理画面のページ番号や総件数表示が必要ならオフセット型。タイムライン・無限スクロールや100万行超の一覧ではカーソル型(キーセット型)を選ぶ。深い OFFSET は DB が全スキップ行を読むため遅くなる。動的に更新されるリストではオフセットは重複・欠落の原因になりやすい。GraphQL なら Relay Connection、REST なら cursor + limit が定番。
ページネーションが必要になる場面
REST API や GraphQL で一覧を返すとき、一度に全件を返すとレスポンスが肥大化し、DB もメモリを食い潰す。limit で件数を絞り、残りを「次のページ」として取得する仕組みがページネーションだ。
よくある要件は次の3つに分かれる。
- 固定ページ送り — 「3ページ目を見たい」(管理画面、検索結果)
- 次へ・無限スクロール — 「今見ている続きから20件」(SNS、ログ、通知)
- 総件数の表示 — 「全 12,345 件中 21〜40 件目」(検索 UI、レポート)
要件によって最適な方式が変わる。方式を間違えると、深いページで数秒待たされたり、スクロール中に同じ投稿が2回出たり、行が飛んで見えなくなったりする。本記事では オフセット型 と キーセット型(カーソル型) の SQL・API 設計・性能・落とし穴を、実装にそのまま落とせる粒度で比較する。
オフセット型(Offset-based)の SQL と REST 設計
最も直感的な方式。SQL では LIMIT と OFFSET を組み合わせる。
-- 3ページ目(1ページ20件): offset = (3 - 1) * 20 = 40
SELECT id, title, created_at, author_id
FROM posts
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
総件数が必要な管理画面では、別クエリで COUNT(*) を返すのが定番だ。
SELECT COUNT(*) AS total
FROM posts
WHERE deleted_at IS NULL;
REST クエリパラメータの例
オフセット型はフロントの実装が簡単で、ページ番号 UI とも相性が良い。
GET /api/v1/posts?limit=20&offset=0
GET /api/v1/posts?limit=20&offset=20
GET /api/v1/posts?limit=20&offset=40&sort=created_at&order=desc
GET /api/v1/posts?limit=20&page=3
page パラメータを受け取る API では、サーバー側で offset = (page - 1) * limit に変換する。レスポンス例:
{
"data": [
{ "id": 9841, "title": "Pagination deep dive", "created_at": "2026-06-18T09:12:00Z" }
],
"pagination": {
"limit": 20,
"offset": 40,
"page": 3,
"total": 12345,
"total_pages": 618
}
}
オフセット型の弱点
PostgreSQL や MySQL では、OFFSET 100000 のように深い位置へジャンプすると、DB はスキップ対象の10万行も読み込んでから捨てることが多い。(created_at DESC, id DESC) に複合インデックスがあっても、深い OFFSET では Index Scan + 大量の行スキップ となり、コストは線形に増える。
さらに、一覧の先頭付近に新規データが挿入されると、ユーザーが2ページ目に進んだ瞬間に1ページ目の末尾と2ページ目の先頭がずれ、同じ行が二度表示されたり、行が飛んだりする。更新頻度の高いフィードでは致命的だ(後述の「無限スクロールの重複バグ」で詳述)。
キーセット型(Keyset)の SQL 実装
カーソル型 API の内部実装は、ほぼ常にキーセット型と呼ばれる DB アクセスパターンだ。「前回取得した最後の行のソートキー値」を次リクエストの WHERE 条件に使う。
前提: ORDER BY created_at DESC, id DESC でソートし、created_at だけでは同一秒の行の順序が不定になるため、タイブレーカーとして id を足すのが定石だ。
-- 初回: limit=20
SELECT id, title, created_at, author_id
FROM posts
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 2回目: 最後の行が created_at='2026-06-18T10:00:00+00', id=9921 の場合
SELECT id, title, created_at, author_id
FROM posts
WHERE deleted_at IS NULL
AND (created_at, id) < ('2026-06-18 10:00:00+00'::timestamptz, 9921)
ORDER BY created_at DESC, id DESC
LIMIT 20;
PostgreSQL の行比較 (created_at, id) < (...) は、複合インデックス (created_at DESC, id DESC) と組み合わせると Index Range Scan になりやすい。MySQL 8 でも同様のタプル比較が使える。
昇順ソートの場合は演算子を反転する。
-- ORDER BY created_at ASC, id ASC の続き
WHERE deleted_at IS NULL
AND (created_at, id) > ('2026-06-17 08:00:00+00'::timestamptz, 8800)
ORDER BY created_at ASC, id ASC
LIMIT 20;
「前のページ」方向(双方向ページネーション)
SNS で上方向スクロール(過去ログの読み込み)をサポートする場合、after と before の両方を用意する。Relay Connection でも before / last がこれに相当する。
-- before カーソル: 指定行より「新しい」方向へ20件
SELECT id, title, created_at
FROM posts
WHERE deleted_at IS NULL
AND (created_at, id) > ('2026-06-18 10:00:00+00'::timestamptz, 9921)
ORDER BY created_at ASC, id ASC
LIMIT 20;
-- 結果はアプリ側で逆順に並べ替えて返す
GraphQL Relay スタイルの cursor エンコーディング
GraphQL では Relay Cursor Connections Specification が事実上の標準だ。edges { node cursor } と pageInfo { hasNextPage endCursor } を返す。
cursor 文字列の中身
Relay の cursor は 不透明(opaque) である必要がある。仕様上、cursor は Base64 エンコードされた文字列で、クライアントは中身を解釈しない。
実装パターンは大きく2つある。
パターンA — Relay 互換の arrayconnection: プレフィックス
Node.js(graphql-relay)の慣習:
// encode: "arrayconnection:9921" → Base64
const cursor = Buffer.from(`arrayconnection:${row.id}`).toString('base64');
// → "YXJyYXljb25uZWN0aW9uOjk5MjE="
// decode
const decoded = Buffer.from(cursor, 'base64').toString('utf8');
// → "arrayconnection:9921"
const id = decoded.replace('arrayconnection:', '');
パターンB — 複合キーを JSON で包んで Base64(REST でも使える)
const payload = { c: row.created_at.toISOString(), id: row.id, v: 1 };
const cursor = Buffer.from(JSON.stringify(payload)).toString('base64url');
// → "eyJjIjoiMjAyNi0wNi0xOEgxMDowMDowWiIsImlkIjo5OTIxLCJ2IjoxfQ"
function decodeCursor(cursor) {
const { c, id, v } = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
if (v !== 1) throw new Error('Unsupported cursor version');
return { created_at: c, id };
}
v(バージョン)フィールドを入れておくと、将来ソートキーを変えたときに旧 cursor を無効化しやすい。
GraphQL クエリとレスポンス例
query Posts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
node { id title createdAt }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
{
"data": {
"posts": {
"edges": [
{
"node": { "id": "9921", "title": "Keyset wins", "createdAt": "2026-06-18T10:00:00Z" },
"cursor": "YXJyYXljb25uZWN0aW9uOjk5MjE="
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "YXJyYXljb25uZWN0aW9uOjk5MjE="
}
}
}
}
サーバー側では after をデコードし、前述のキーセット SQL の WHERE に変換する。cursor の改ざんは署名付きトークン(HMAC)や、デコード後の型・範囲検証で防ぐ。
REST API の cursor パラメータ設計
REST では GraphQL よりパラメータ名の自由度が高いが、cursor + limit が最も広く使われる。
GET /api/v1/posts?limit=20
GET /api/v1/posts?limit=20&cursor=eyJjIjoiMjAyNi0wNi0xOEgxMDowMDowWiIsImlkIjo5OTIxfQ==
GET /api/v1/posts?limit=20&cursor=eyJjIjoiMjAyNi0wNi0xOEgxMDowMDowWiIsImlkIjo5OTIxfQ==&sort=-created_at
レスポンス例(GitHub / Stripe 系のスタイル):
{
"object": "list",
"data": [ /* 20件 */ ],
"has_more": true,
"next_cursor": "eyJjIjoiMjAyNi0wNi0xOEgxMDowMDowWiIsImlkIjo5OTIxfQ=="
}
別スタイルとして Link ヘッダー(RFC 8288)を使う API もある。
HTTP/1.1 200 OK
Link: </api/v1/posts?limit=20&cursor=eyJj...>; rel="next"
設計上のルール
| ルール | 理由 |
|---|---|
limit に上限(例: max 100) | 巨大レスポンスと DB 負荷を防ぐ |
sort 変更時は cursor を無効化 | ソートキーが変わると cursor の意味が変わる |
| cursor は不透明・検証必須 | 改ざんで他ユーザーの行や未来の行へジャンプされる |
has_more / next_cursor を明示 | クライアントが空ページをループしない |
ハイブリッド: 浅い offset + 深い cursor
Stripe などは浅いページは starting_after(cursor)のみだが、管理系 API では page 1〜50 は offset、51 以降は cursor 必須 のような折衷も現場で使われる。
GET /api/v1/orders?limit=20&offset=0
GET /api/v1/orders?limit=20&offset=980
GET /api/v1/orders?limit=20&cursor=ord_abc123 # offset>=1000 は 400、cursor を使え
無限スクロールで起きる重複・欠落バグ
オフセット型をタイムラインに使うと、挿入・削除・更新のタイミングで UI が壊れやすい。具体シナリオで追う。
重複バグ: 先頭への新規投稿
時系列降順(新しい順)のフィード。1ページ目を LIMIT 20 OFFSET 0 で取得した直後、誰かが新しい投稿 P_new を INSERT する。ユーザーがスクロールして2ページ目 OFFSET 20 を取得する。
【1ページ目取得時の並び】
行1: id=100 ← 表示
行2: id=99
...
行20: id=81 ← 1ページ目の最後
【P_new (id=101) 挿入後、DB上の並び】
行1: id=101 ← 新規(1ページ目に入るべき)
行2: id=100 ← 以前の1ページ目先頭
...
行21: id=81 ← 以前の1ページ目最後
【2ページ目 OFFSET 20 で取得】
先頭: id=81 ← 1ページ目で既に表示済み → 重複
次: id=80
...
ユーザーには id=81 の投稿が2回 表示される。Twitter や Instagram のタイムラインで「さっき見たのにまた出てきた」現象の典型原因だ。
欠落バグ: 先頭からの削除
逆に、1ページ目表示中に先頭行 id=100 が削除(または非表示)されると、2ページ目 OFFSET 20 の先頭は id=79 になり、id=80 が一覧から消える(スキップされる)。
カーソル型での挙動
1ページ目の最後の行 { created_at: T81, id: 81 } を cursor として保持し、2ページ目は WHERE (created_at, id) < (T81, 81) で取得する。途中で id=101 が挿入されても、すでに返した id=81 より新しい行は2ページ目に混ざらない。重複は起きにくい。
注意: カーソル型でも 削除 された行の「穴」は残る(欠落は許容される設計が多い)。リアルタイムで穴を埋めたい場合は WebSocket 配信やポーリングで別途補完する。
フロント実装の落とし穴
無限スクロール UI では次も重複の原因になる。
- 二重 fetch: スクロールイベントで
loadMore()が2回走り、同じ cursor で2リクエスト →dedupeKey=idでマージする - React Strict Mode: 開発環境で useEffect が2回実行され、同じページを2回 append する
- cursor の stale 保持: フィルタ変更後も古い cursor を送る → 400 または意図しない結果
// 重複排除の例
function appendPosts(prev, incoming) {
const seen = new Set(prev.map(p => p.id));
return [...prev, ...incoming.filter(p => !seen.has(p.id))];
}
EXPLAIN による性能比較(PostgreSQL)
100万行の posts テーブル((created_at, id) に複合インデックスあり)で、深いページ取得のコスト差を見る。
オフセット型: 深いページ
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM posts
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
典型的な出力(環境により数値は異なる):
Limit (cost=... rows=20 width=...)
-> Index Scan Backward using posts_created_at_id_idx on posts
(actual time=842.123..853.456 rows=100020 loops=1)
Filter: (deleted_at IS NULL)
Rows Removed by Filter: 0
Buffers: shared hit=120034
Planning Time: 0.15 ms
Execution Time: 853.789 ms
ポイント: rows=100020 — 10万行スキップ + 20行返却 のために、インデックス上を10万行以上走査している。OFFSET が倍になるほど Execution Time もおおむね比例して悪化する。
キーセット型: 同じ論理位置
100,000 件目付近の (created_at, id) を事前に取得し、その cursor から続きを取る。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM posts
WHERE deleted_at IS NULL
AND (created_at, id) < ('2024-03-15 14:22:00+00'::timestamptz, 450123)
ORDER BY created_at DESC, id DESC
LIMIT 20;
典型的な出力:
Limit (cost=... rows=20 width=...)
-> Index Scan Backward using posts_created_at_id_idx on posts
(actual time=0.045..0.089 rows=20 loops=1)
Index Cond: ((created_at, id) < ('2024-03-15 14:22:00+00', 450123))
Filter: (deleted_at IS NULL)
Buffers: shared hit=24
Planning Time: 0.12 ms
Execution Time: 0.102 ms
ポイント: rows=20、Buffers も桁違いに少ない。深い位置でも「境界から20件」だけ読むため、Execution Time はミリ秒台で安定する。
COUNT(*) のコスト
オフセット型で total / total_pages を返す場合、COUNT(*) 自体が重いことがある。
EXPLAIN (ANALYZE)
SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL;
-- Seq Scan on posts (actual time=120.456..120.458 rows=1 loops=1)
-- rows=1000000
-- Execution Time: 120.512 ms
大規模テーブルでは 近似件数(PostgreSQL の pg_class.reltuples、またはキャッシュ)を使う API も増えている。カーソル型のフィードでは総件数を省略する設計が一般的だ。
3方式の比較
| 観点 | オフセット / キーセット(カーソル) |
|---|---|
| SQL | LIMIT n OFFSET m / WHERE (sort_key) < (cursor) LIMIT n |
| REST 例 | ?limit=20&offset=40 / ?limit=20&cursor=eyJj... |
| GraphQL | 非標準(独自 page/offset) / Relay Connection(first/after) |
| 深いページの性能 | OFFSET 比例で悪化 / インデックス範囲スキャンで安定 |
| データ更新時 | 挿入・削除でずれ・重複 / 取得済み境界を保持しやすい |
| ページ番号 UI | 向く / 向かない(次・前のみ) |
| 総件数 | COUNT(*) と組み合わせやすい / 別途・省略が多い |
| 実装コスト | 最も簡単 / encode・検証・複合キー・双方向 |
ユースケース別の選定
ケースA — 社内管理画面のユーザー一覧(5,000件、ページ番号あり)
オフセット型で十分。COUNT(*) で総件数を返し、offset = (page - 1) * limit でページ送りする。更新頻度は低く、深いページも稀。
ケースB — 公開タイムライン(数百万件、無限スクロール)
カーソル型を採用。next_cursor だけ返し、総件数は省略。ソートは (created_at DESC, id DESC) 固定。GraphQL なら Relay Connection、REST なら cursor + has_more。
ケースC — 検索結果(フィルタ可変、2ページ目以降も必要)
条件が変わるたび cursor はリセット。ページ番号が必要なら、検索条件を固定したうえで浅い offset のみ許可し、深いページは Elasticsearch の search_after や OpenSearch の point in time + search_after を検討する。RDB の深い OFFSET を検索結果全件に使うのは性能劣化しやすい。
ケースD — 監査ログのエクスポート(百万行、順次読み取り)
キーセット型の cursor でループし、CSV にストリーム書き出し。OFFSET は使わない。limit=1000 などバッチサイズを大きくし、WHERE (created_at, id) > last_cursor で前進する。
cursor をクライアントが自由に改ざんできる設計にすると、権限のない行へのアクセスや 意図しない条件の注入につながる。cursor は HMAC 署名付きにするか、デコード後に型・範囲・テナント ID を必ず検証する。GraphQL の first にも上限(例: 100)を設ける。
実装チェックリスト
ソートキーに (created_at, id) などの複合インデックスがあるか確認する
カーソル型なら encode/decode・バージョン番号・ソート変更時の無効化を実装する
オフセット型なら offset 上限(例: 10,000)を設け、超過時は cursor を促す
EXPLAIN ANALYZE で offset=0 と offset=100000、キーセット cursor の Execution Time を記録する
挿入・削除シナリオで無限スクロールの重複・欠落を手動または E2E で検証する
limit の default(20)と max(100)を OpenAPI / GraphQL スキーマに明記する
フロントで id ベースの dedupe と、二重 loadMore 防止(inFlight フラグ)を入れる
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
まとめ
オフセット型は 実装が早く、ページ番号 UI と総件数に強い。キーセット型(カーソル型)は 大規模データと無限スクロールに強く、更新の多い一覧で一貫性を保ちやすい。SQL レベルでは OFFSET が深いほどスキップ行を読み続けるのに対し、キーセットは WHERE (sort_key) < cursor で必要な行だけ取れる。REST では ?limit=&cursor=、GraphQL では Relay の first / after が定番だ。
新規 API では、まずユースケース(固定ページか連続スクロールか)とデータ規模を決め、ソートキーとインデックス設計まで含めて方式を選ぶ。動的フィードに OFFSET をそのまま使うと、重複バグはいつか必ず報告される——その前に cursor に切り替えておくのが安全だ。