API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較

API ページネーション REST GraphQL バックエンド 設計
結論

管理画面のページ番号総件数表示が必要ならオフセット型。タイムライン・無限スクロール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 では LIMITOFFSET を組み合わせる。

-- 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 で上方向スクロール(過去ログの読み込み)をサポートする場合、afterbefore の両方を用意する。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=10002010万行スキップ + 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)を設ける。

実装チェックリスト

1

ソートキーに (created_at, id) などの複合インデックスがあるか確認する

2

カーソル型なら encode/decode・バージョン番号・ソート変更時の無効化を実装する

3

オフセット型なら offset 上限(例: 10,000)を設け、超過時は cursor を促す

4

EXPLAIN ANALYZE で offset=0 と offset=100000、キーセット cursor の Execution Time を記録する

5

挿入・削除シナリオで無限スクロールの重複・欠落を手動または E2E で検証する

6

limit の default(20)と max(100)を OpenAPI / GraphQL スキーマに明記する

7

フロントで 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 に切り替えておくのが安全だ。