TOOLS BOX/ガイド/offset vs cursor ページネーション
Concept

offset vs cursor ページネーション

Prisma の offset 方式(skip/take)と cursor 方式の違いを比較し、実装状況や UI 要件に応じた選び分けを整理するガイド。

prismapaginationoffsetcursorskiptakeinfinite-scroll

どういう場面で使うか

  • ·offset 方式: ページ番号 UI が必要・総件数を表示したい・任意ページへジャンプしたいとき
  • ·cursor 方式: 無限スクロール・「次へ」ナビのみ・大量データで性能を安定させたいとき

注意点 / Pitfalls

  • ·offset 方式はスキップ量が増えるほど DB スキャン量が増加する(数万件超で顕著)
  • ·cursor 方式では totalPages が出せないため、ページ番号 UI との組み合わせは難しい
  • ·cursor に使うフィールドには一意性と DB インデックスが必要

補足

最初は offset 方式で成立させ、件数が増えてきたら cursor 方式への移行を検討するのが現実的。どちらか一方しか使えないわけではなく、同じアプリで混在させることもある。

何が違うか

offset(skip / take)cursor
ページ指定件数ベース(N 件スキップ)レコード位置ベース(どの ID の次)
大量データの性能スキップ量が増えるほど遅くなる常に一定
総件数の取得容易(count と並列取得)困難
ページ番号 UI向く向かない
無限スクロール使えるが非推奨向く
任意ページへのジャンプ簡単(page=N困難(途中の cursor 値が必要)
実装の複雑さシンプルやや複雑(nextCursor の管理が必要)

性能面の違い

offset 方式は内部で「N 件スキップ」を実行するため、スキップ量が増えるほど DB がスキャンする行数が増えます。数万件を超えたテーブルで最終ページを取得すると顕著に遅くなります。

// offset 方式: page が大きくなるほど skip が増えて遅くなる
prisma.post.findMany({
  skip: (page - 1) * limit, // page=1000 なら skip=9990
  take: limit,
  orderBy: { createdAt: "desc" },
});

cursor 方式はスキップではなく「特定レコードの次から取得」するため、ページが深くなっても性能が変わりません。

// cursor 方式: 何ページ目でも同じコスト
prisma.post.findMany({
  cursor: cursor ? { id: Number(cursor) } : undefined,
  skip: cursor ? 1 : 0,
  take: limit + 1,
  orderBy: { id: "desc" },
});

総件数・ページ番号 UI との相性

offset 方式では count クエリと findManyPromise.all で並列実行するだけで総件数が取れます。

const [total, items] = await Promise.all([
  prisma.post.count({ where }),
  prisma.post.findMany({ where, skip, take, orderBy }),
]);
const totalPages = Math.ceil(total / limit);

cursor 方式では「現在のカーソル位置より後に何件あるか」を効率よく取得する手段がなく、総ページ数の表示が難しくなります。「1 / 10 ページ」のような UI は offset 方式が適しています。

無限スクロール・「次へ」ナビとの相性

cursor 方式は nextCursor を返して次回リクエストで使う設計と相性が良く、無限スクロールのような「後続データを順次取得する」UI に向きます。

// take: limit + 1 で次ページ存在を判定
const hasNextPage = items.length > limit;
const nextCursor = hasNextPage ? String(items[limit - 1].id) : null;

return { items: items.slice(0, limit), nextCursor };

安定ソートと一意キーの必要性

どちらの方式でも、ページをまたいで重複や欠落が生じないよう安定したソートが必要です。

  • createdAt だけでソートすると、同一タイムスタンプのレコードで順序が変わる可能性がある
  • 最終ソートキーに id などの一意フィールドを追加することで安定ソートになる
orderBy: [{ createdAt: "desc" }, { id: "desc" }] // id を補助キーに

cursor 方式では cursor に使うフィールドに一意性と DB インデックスが必要です。通常 @id は自動でインデックス付きのため id を cursor に使うのが最もシンプルです。

選び分けの目安

ページ番号 UI や総件数表示が必要か?
├── Yes → offset 方式
└── No
    ├── データが数万件以上か?
    │   ├── Yes → cursor 方式(性能安定)
    │   └── No  → どちらでも可(offset の方がシンプル)
    └── 無限スクロールや「次へ」のみの UI か?
        └── Yes → cursor 方式

関連サンプルの見どころ

  • prisma-pagination-query: offset 方式と cursor 方式の両パターンを Route Handler で実装する例

関連サンプル

同じテーマや技術スタックを使った実装例