管理画面API向け:高速なカーソル vs オフセットページネーション
ソート、フィルタ、総数に対して一貫したAPI契約を持ち、Webとモバイルの管理画面で高速に動くカーソルとオフセットのページネーションを学びます。

なぜページネーションで管理画面が遅く感じるのか
管理画面は最初は単純なテーブルから始まります:最初の25行を読み込み、検索ボックスを追加して終わり。数百件のレコードでは瞬時に感じます。しかしデータセットが増えると、同じ画面がカクつき始めます。
問題はたいていUIではありません。APIがページ12をソートやフィルタを反映して返す前に行う処理です。テーブルが大きくなると、バックエンドは一致する行を探し、数え、前の結果をスキップするのにより多くの時間を費やします。もしクリック1つごとに重いクエリが発生するなら、画面は反応する代わりに考えているように感じます。
同じ場所で目に付きやすいです:ページ移動が時間とともに遅くなり、ソートが鈍くなり、検索がページによって不整合に感じられ、無限スクロールはバースト的に読み込む(速い、そして突然遅い)ようになります。負荷の高いシステムでは、リクエスト間でデータが変わると重複や行の欠落が発生することさえあります。
WebとモバイルのUIはページネーションを別方向に押します。Webの管理テーブルは特定ページへジャンプしたり多くの列でソートすることを促します。モバイル画面は通常、次のチャンクを読み込む無限リストを使い、各読み込みが同じ速さであることをユーザーが期待します。APIがページ番号だけで作られているとモバイルが苦しみます。逆に次/afterだけで作られているとWebテーブルは制限を感じます。
目標は単に25件を返すことではありません。データが増えても速く、予測可能なページングであること。テーブルと無限リストの両方で同じように動作するルールを持つことです。
UIが依存するページネーションの基本
ページネーションは長いリストを小さな塊に分けて画面が速く読み込み・描画できるようにすることです。UIはすべてのレコードを要求する代わりに次のスライスを要求します。
最も重要な制御はページサイズ(多くは limit と呼ぶ)です。小さなページはサーバーの処理が少なく、アプリが描画する行数も少ないため速く感じられます。しかしページが小さすぎるとユーザーは何度もクリックやスクロールをしなければならず落ち着かなく感じます。多くの管理テーブルでは25〜100件が実用的な範囲で、モバイルは通常より小さい方を好みます。
安定したソート順は多くのチームが想定する以上に重要です。リクエスト間で順序が変わると、ページング中に重複や欠落が見えます。安定したソートは通常、主となるフィールド(例えば created_at)とタイブレーカー(例えば id)での並びを意味します。これはオフセットでもカーソルでも重要です。
クライアントから見て、ページ付きレスポンスはアイテム、次ページのヒント(ページ番号かカーソルトークン)、そしてUIが本当に必要とするカウントだけを含むべきです。ある画面では「1-50 of 12,340」の正確な総数が必要でしょう。別の画面では has_more だけで十分です。
オフセットページネーション:仕組みと問題点
オフセットページネーションは古典的なページN方式です。クライアントは固定数の行を要求し、最初に何行スキップするかをAPIに伝えます。これを limit と offset、またはサーバーがオフセットに変換する page と pageSize として見ることができます。
典型的なリクエストは次のようになります:
GET /tickets?limit=50\u0026offset=950- 「最初の950行をスキップして50件のチケットをください」
ページ20にジャンプしたり、古いレコードをスキャンしたり、大きなリストをチャンクでエクスポートするなど、一般的な管理上のニーズに合致します。内部的に説明もしやすい:「ページ3を見ればわかるよ。」
問題は深いページで顕在化します。多くのデータベースはスキップした行を読み飛ばすために時間がかかることがあり、特にソート順がきっちりとしたインデックスに支えられていない場合に顕著です。ページ1は速くてもページ200は目に見えて遅くなり、それがユーザーがスクロールやジャンプをするときに管理画面が遅いと感じる原因です。
もう1つの問題はデータが変わったときの整合性です。サポートマネージャーが最新順でページ5を開いている間に新しいチケットが入ったり古いチケットが削除されたと想像してください。挿入はアイテムを前に押しやり(ページ間での重複)、削除はアイテムを後ろにずらす(ユーザーが辿っている経路から消える)可能性があります。
オフセットページネーションは小さなテーブルや安定したデータセット、ワンオフのエクスポートでは問題にならないこともあります。大きくてアクティブなテーブルではエッジケースがすぐに出てきます。
カーソルページネーション:仕組みと安定性の理由
カーソルページネーションはブックマークとしてカーソルを使います。「7ページ目をください」と言う代わりにクライアントは「この正確なアイテムの後から続けてください」と伝えます。カーソルは通常、最後のアイテムのソート値(例:created_at と id)をエンコードして、サーバーが正しい位置から再開できるようにします。
リクエストは通常次の通りです:
limit: 返すアイテム数cursor: 前回のレスポンスからの不透明トークン(多くはafterと呼ぶ)
レスポンスはアイテムとスライスの末尾を指す新しいカーソルを返します。実務上の違いは、カーソルはデータベースに行の総数を数えたりスキップしたりするのではなく、既知の位置から始めるように要求することです。
そのためカーソルページネーションは順次読み込みのリストで速さを保ちます。良いインデックスがあればデータベースは「Xより後のアイテム」にジャンプして、次の limit 行を読み取れます。オフセットではオフセットが大きくなるとサーバーがより多くの行をスキャン(少なくともスキップ)する必要があることが多いです。
UIの挙動としては、カーソルは「次へ」を自然にします:返されたカーソルを次のリクエストで送り返せばよいのです。「前へ」はオプションで実装が難しいことがあります。あるAPIは before カーソルをサポートし、別のAPIは逆向きに取得して結果を反転させます。
カーソル、オフセット、またはハイブリッドの選び方
選択はまずユーザーがリストをどのように使うかから始まります。
カーソルは主に前へ進む操作が行われ、速度が最重要な場合に最適です:アクティビティログ、チャット、注文、チケット、監査トレイル、そして多くのモバイル無限スクロールなど。新規行の挿入や削除が発生しても動作が良いです。
オフセットはユーザーが頻繁にあちこちジャンプする古典的な管理テーブルに向きます:ページ番号、指定ページへ飛ぶ機能、素早い往復操作など。説明が簡単ですが、大規模データで遅くなったり、データが下で変わると不安定になります。
実用的な決め方の目安:
- 主な操作が「次、次、次」であるならカーソルを選ぶ。
- 「ページNへジャンプ」が本当に必要ならオフセットを選ぶ。
- 総数は任意と扱う。巨大なテーブルで正確な総数は高コストになり得る。
ハイブリッドはよく使われます。1つの方法は高速な次/前のためにカーソルを使い、ページジャンプが必要な小さなフィルタ済みサブセットではオフセットを使う方法です。別の方法はキャッシュされたスナップショットに基づくページ番号を提供して表の馴染みを残しつつ、すべてのリクエストを重い処理にしない方法です。
Webとモバイル両方で動く一貫したAPI契約
管理UIは各リストエンドポイントが同じように振る舞うと速く感じます。UIが変わっても(ページ番号のあるWeb表、無限スクロールのモバイル)API契約は変えず、エンドポイントごとにページネーションルールを学び直す必要がないようにします。
実用的な契約は3つの部分で構成します:行(rows)、ページング状態、そして任意の総数(totals)。エンドポイント名(tickets、users、orders)は同じ名前を保ち、内部のページングモードが異なってもフィールド名を揃えます。
両方でうまく働くレスポンス形状の例:
{
"data": [ { "id": "...", "createdAt": "..." } ],
"page": {
"mode": "cursor",
"limit": 50,
"nextCursor": "...",
"prevCursor": null,
"hasNext": true,
"hasPrev": false
},
"totals": {
"count": 12345,
"filteredCount": 120
}
}
いくつかの細かい点で再利用が簡単になります:
page.modeはサーバーが何をしているかをクライアントに伝え、フィールド名を変えずに済ませます。limitは常に要求されたページサイズです。nextCursorとprevCursorはどちらかが null でも両方存在します。totalsは任意です。高コストならクライアントが要求したときだけ返してください。
Webの表は自身で「ページ3」を表示するために自分のページインデックスを保持してAPIを繰り返し呼ぶことができます。モバイルリストはページ番号を無視して単に次のチャンクを要求できます。
AppMasterでWebとモバイルの管理UIの両方を作っているなら、このような安定した契約はすぐに効果を発揮します。同じリスト動作をスクリーン間で再利用でき、エンドポイントごとにカスタムなページネーションロジックを作る必要が減ります。
ページネーションを安定させるソートのルール
ソートはページネーションが壊れやすいところです。リクエスト間で順序が変わると、重複やギャップ、あるいは「行が消えた」ように見えます。
ソートを提案ではなく契約にしてください。許可されたソートフィールドと方向を公開し、それ以外は拒否します。これにより予測可能なAPIになり、開発時には問題にならないが本番で遅くなるソート要求を防げます。
安定したソートには一意のタイブレーカーが必要です。例えば created_at でソートして2つのレコードが同じタイムスタンプを持つ場合、最後に id(または一意の別カラム)を追加します。これがないとデータベースは等しい値を任意の順序で返す可能性があります。
実用的なルール:
- インデックス化され定義が明確なフィールドだけでソートを許可する(例:
created_at、updated_at、status、priority)。 - いつでも最後に一意のタイブレーカーを含める(例:
id ASC)。 - デフォルトソートを定義してクライアント間で一貫させる(例:
created_at DESC, id DESC)。 - null のソート順を文書化する(例:日付と数値は「nulls last」)。
ソートはカーソル生成にも影響します。カーソルは最後のアイテムのソート値の順序をエンコードし、タイブレーカーも含めるべきです。ソートが変わると古いカーソルは無効になります。ソートパラメータをカーソル契約の一部として扱ってください。
契約を壊さずにフィルタと総数を扱う
フィルタはページネーションから切り離して考えるべきです。UIは「別の行集合を見せてください」と言い、その後で「その集合をページングしてください」と言います。フィルタをページネーショントークンに混ぜたり、フィルタを任意で検証しないと、空のページ、重複、あるいはカーソルが別のデータセットを指すといったデバッグの難しい挙動になります。
単純なルール:フィルタはプレーンなクエリパラメータ(またはPOSTのリクエストボディ)に置き、カーソルは不透明でその正確なフィルタ+ソートの組み合わせに対してのみ有効にしてください。ユーザーがフィルタ(status、日付範囲、担当者)を変更したら、クライアントは古いカーソルを破棄して最初から始めるべきです。
許可するフィルタを厳格にすることは性能を守り、挙動を予測可能にします:
- 不明なフィルタフィールドは拒否する(無視しない)。
- 型と範囲を検証する(日付、列挙型、ID)。
- 広範囲なフィルタに上限を設ける(例:INリストは最大50 ID)。
- データと総数に同じフィルタを適用する(不一致な数値を返さない)。
総数は多くのAPIを遅くします。正確なカウントは大量テーブルでフィルタがあると高コストになります。一般的に3つの選択肢があります:正確、推定、またはなし。正確は小規模データやユーザーが本当に「1-25 of 12,431」を必要とする場合に有益です。推定は多くの管理画面で十分です。不要なら「なし」で has_more だけ返す運用もあります。
すべてのリクエストを遅くしないために、総数は任意にしてください:クライアントが要求したときだけ計算する(例:includeTotal=true)、フィルタセットごとに短時間キャッシュする、または最初のページだけで返す、などです。
ステップバイステップ:エンドポイントの設計と実装
デフォルトから始めてください。リストエンドポイントは安定したソート順と同値の行に対するタイブレーカーが必要です。例えば:createdAt DESC, id DESC。タイブレーカー(id)が新しいレコードの追加時に重複やギャップを防ぎます。
1つのリクエスト形を定義して平凡に保ちます。典型的なパラメータは limit、cursor(または offset)sort、そして filters です。両方のモードをサポートする場合は相互排他にしてください:クライアントは cursor を送るか offset を送るかのどちらかで、両方同時は不可です。
一貫したレスポンス契約を保ち、Webとモバイルが同じリストロジックを共有できるようにします:
items: レコードのページnextCursor: 次ページを取得するカーソル(またはnull)hasMore: UIが「もっと読み込む」を表示するか決めるための真偽値total: マッチする総レコード数(高コストなら要求時のみ、なければnull)
実装はここでオフセットとカーソルで分岐します。
オフセットクエリは通常 ORDER BY ... LIMIT ... OFFSET ... で書かれ、大きなテーブルでは遅くなりがちです。
カーソルクエリは最後に見たアイテムに基づくシーク条件を使います:「(createdAt, id) が最後の (createdAt, id) より小さいものをください」のように。これによりインデックスが使われ、パフォーマンスがより安定します。
出荷前にガードレールを追加してください:
limitに上限を設ける(例:最大100)とデフォルトを設定する。sortを許可リストで検証する。- フィルタは型で検証し、不明なキーは拒否する。
cursorは不透明にし(最後のソート値をエンコード)、不正なカーソルは拒否する。totalの取得方法を決める。
データが途中で変化する状況でテストしてください。リクエストの間にレコードを作成・削除したり、ソートに影響するフィールドを更新して、重複や欠落が発生しないことを確認します。
例:Webとモバイルで速いままのチケット一覧
サポートチームが最新のチケットを確認する管理画面を開きます。新しいチケットが到着したりエージェントが古いものを更新してもリストは瞬時に感じる必要があります。
WebではUIは表です。デフォルトソートは updated_at(最新順)で、チームはよく Open や Pending にフィルタします。同じエンドポイントが安定したソートとカーソルトークンで両方をサポートできます。
GET /tickets?status=open\u0026sort=-updated_at\u0026limit=50\u0026cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wMS0yNVQxMTo0NTo0MloiLCJpZCI6IjE2OTMifQ==
レスポンスはUIにとって予測可能です:
{
"items": [{"id": 1693, "subject": "Login issue", "status": "open", "updated_at": "2026-01-25T11:45:42Z"}],
"page": {"next_cursor": "...", "has_more": true},
"meta": {"total": 128}
}
モバイルでは同じエンドポイントが無限スクロールを支えます。アプリは一度に20件ずつ読み込み、次のバッチを取得するために next_cursor を送ります。ページ番号ロジックは不要で、レコードが変わってもスクロール済みのフィードに重複や欠落が少なくなります。
重要なのはカーソルが最後に見た位置(例:updated_at とタイブレーカーとしての id)をエンコードしていることです。チケットがスクロール中に更新されると、次回のリフレッシュで上の方に移動するかもしれませんが、既にスクロールした部分に重複やギャップを生むことはありません。
総数は便利ですが大規模データでは高コストです。単純なルールは、ユーザーがフィルタを適用したとき(例:status=open)や明示的に要求したときだけ meta.total を返すことです。
重複、ギャップ、遅延を招く一般的なミス
多くのページネーションバグはデータベース自体ではなく、テストでは問題にならない小さなAPI設計の判断ミスから生じます。
最も一般的な重複(または欠落)の原因は、一意ではないフィールドでソートすることです。created_at でソートして2つのアイテムが同じタイムスタンプを持つと、リクエスト間で順序が入れ替わることがあります。修正は簡単です:常に安定したタイブレーカー(通常は主キー)を追加し、ソートを (created_at desc, id desc) のようなペアとして扱います。
別の一般的な問題はクライアントに任せて無制限のページサイズが許されることです。1つの大きなリクエストがCPU、メモリ、レスポンスタイムをスパイクさせ、すべての管理画面を遅くします。適切なデフォルトと厳格な最大値を設け、大きすぎる要求はエラーで返してください。
総数も問題になり得ます。すべてのリクエストで一致する行を数えると最も遅い部分になることがあり、特にフィルタがあると顕著です。UIが総数を必要とする場合は、要求時にのみ取得するか(あるいは概算を返す)、リストのスクロールを完全なカウントでブロックしないようにしてください。
重複、ギャップ、遅延を引き起こす典型的なミス:
- 一意のタイブレーカーなしでのソート(順序が不安定)
- 無制限のページサイズ(サーバー過負荷)
- 毎回の総数返却(遅いクエリ)
- 1つのエンドポイントでオフセットとカーソルのルールを混在させる(クライアント挙動が混乱)
- フィルタやソートが変わったのに同じカーソルを再利用する(誤った結果)
フィルタやソートが変わったらページネーションをリセットしてください。新しいフィルタは新しい検索として扱い、カーソル/オフセットをクリアして最初から始めます。
出荷前の簡単チェックリスト
APIとUIを並べて1回これを実行してください。多くの問題はリスト画面とサーバー間の契約で起きます。
- デフォルトソートは安定しており一意のタイブレーカーを含む(例:
created_at DESC, id DESC)。 - ソートフィールドと方向はホワイトリスト化されている。
- 最大ページサイズが強制され、妥当なデフォルトがある。
- カーソルトークンは不透明で、無効なカーソルは予測可能に失敗する。
- フィルタやソートの変更はページネーション状態をリセットする。
- 総数の挙動は明示的:正確、推定、または省略のいずれか。
- 同じ契約がテーブルと無限スクロールの両方をサポートする。
次のステップ:リストを標準化して一貫性を保つ
毎日使われる管理リストを1つ選び、それをゴールドスタンダードにしてください。Tickets、Orders、Users のような多くのトラフィックがある表が良い出発点です。そのエンドポイントが速く予測可能になったら、同じ契約を他の管理画面にもコピーしてください。
契約を書き残してください。簡潔でも構いません。APIが受け付けるものと返すものを明確にしておけば、UIチームが推測してエンドポイントごとに異なるルールを勝手に作ることを防げます。
すべてのリストエンドポイントに適用するシンプルな標準:
- 許可されるソート:正確なフィールド名、方向、明確なデフォルト(と
idのようなタイブレーカー)。 - 許可されるフィルタ:どのフィールドをフィルタできるか、値の形式、無効なフィルタ時の挙動。
- 総数の挙動:いつ正確なカウントを返すか、いつ「不明」を返すか、いつ省略するか。
- レスポンス形状:一貫したキー(
items、ページ情報、適用されたソート/フィルタ、総数)。 - エラールール:一貫したステータスコードと読みやすい検証メッセージ。
AppMaster(appmaster.io)で管理画面を構築しているなら、ページネーション契約を早い段階で標準化するのが有効です。同じリスト動作をWebアプリとネイティブモバイルアプリで再利用でき、後でページネーションのエッジケースを追いかける時間を減らせます。
よくある質問
オフセットページネーションは limit と offset(または page/pageSize)を使って行をスキップします。データベースがスキップすべき行数が増えるほど深いページは遅くなりがちです。一方でカーソルページネーションは最後に見たアイテムのソート値を基にしたafterトークンを使い、既知の位置から読み進めることで前へ進み続ける際に高速で安定します。
ページ1はたいてい高速ですが、ページ200ではデータベースが大量の行をスキップする必要があり、それが遅さの原因になります。ソートやフィルタも入ると処理量が増し、各操作が重いクエリのように感じられます。
必ず一意性のあるタイブレーカーを含めた安定したソートを使ってください。例えば created_at DESC, id DESC や updated_at DESC, id DESC のようにすると、同一タイムスタンプのレコード同士で順序が入れ替わるのを防ぎ、ページ間での重複や欠落を防げます。
人が主に前へ進む(次へ、次へ)使い方をするリスト、例としてアクティビティログ、チケット、注文、モバイルの無限スクロールなどではカーソルページネーションを使ってください。挿入や削除が起きてもカーソルは最後に見た位置を基にするため整合性が保ちやすいです。
“ページNへジャンプ”が実際に必要で、ユーザーが頻繁に飛び回るような古典的な管理テーブルではオフセットが適しています。小さなテーブルやデータの変化が少ない安定したデータセットでも問題になりにくいです。
エンドポイント間で同じレスポンス形状を保ち、items、ページ情報、任意の totals(総数)を返すようにしてください。実用的なデフォルトは items、page オブジェクト(limit、nextCursor/prevCursor または offset)、そして hasNext のような軽量フラグです。こうすればWebのテーブルとモバイルのリストで同じクライアントロジックを再利用できます。
大規模でフィルタが多いテーブルでは正確な COUNT(*) がリクエスト毎に高コストになることがあり、これが遅さの原因になります。安全なデフォルトは総数を任意にし、要求があったときのみ返す、あるいは概算を返す、またはUIが「もっと読み込む」だけで良いなら has_more を返すことです。
フィルタはデータセットを変える操作なので、カーソルはそのフィルタとソートの組み合わせにのみ有効であるべきです。ユーザーがフィルタやソートを変更したら古いカーソルは破棄して先頭から開始してください。古いカーソルを再利用すると空ページや混乱が生じやすくなります。
許可されたソートフィールドと方向をホワイトリスト化して、クライアントが遅いか不安定なソートを要求できないようにします。インデックス化されたフィールドでソートし、必ず一意のタイブレーカー(例:id)を末尾に追加して、リクエスト間で順序が決定的になるようにしてください。
limit の最大値を強制し、フィルタとソートパラメータを検証し、カーソルトークンを不透明にして厳密に検証してください。AppMasterで管理画面を作るなら、これらのルールを全てのリストエンドポイントで一貫させると、画面ごとの個別対応を減らせます。


