何が違うか
| 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 クエリと findMany を Promise.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 で実装する例