データが変化してもリストを安定させるにはカーソルページネーションが有効です。挿入や削除でオフセットが壊れる理由と、安全なカーソル実装方法を解説します。

フィードを開いて少しスクロールしていると、何かがおかしくなる瞬間があります。同じ項目が二度表示される。確かに見えたはずの項目が消える。タップしようとした行が下にずれて、意図しない詳細ページを開いてしまう。
こうした現象は API のレスポンスが個別に見れば「正しく」見えても、ユーザーにとっては明らかなバグです。よくある症状は次の通りです:
モバイルではさらに悪化します。人はアプリを一時停止したり、別のアプリに切り替えたり、接続が途切れたりしてから続けます。その間に新しい項目が入り、古い項目が消え、いくつかは編集されます。アプリがオフセットで「ページ3」を要求し続けると、ユーザーがスクロール中にページ境界がずれてしまいます。結果として、フィードは不安定で信用できないものに感じられます。
目標はシンプルです: ユーザーが前方にスクロールし始めたら、そのリストはスナップショットのように振る舞うべきです。新しい項目は存在しても、既にユーザーがページングしている内容を再配置してはいけません。ユーザーには滑らかで予測可能な順序を提供すること。
どのページング方法も完璧ではありません。現実のシステムには同時書き込み、編集、複数のソートオプションが存在しますが、カーソルページネーションは通常オフセットより安全です。なぜなら、行数の移動ではなく、順序上の特定の位置からページングするからです。
オフセットページネーションは「N個スキップしてM個取得する」方式です。API にどれだけスキップするか(offset)と何件返すか(limit)を伝えます。limit=20 ならページごとに20件取得します。
概念的には:
GET /items?limit=20&offset=0(1ページ目)GET /items?limit=20&offset=20(2ページ目)GET /items?limit=20&offset=40(3ページ目)レスポンスには通常、項目と次ページを要求するための情報が含まれます。
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
テーブル、管理画面の一覧、検索結果、シンプルなフィードには採用しやすく、SQL の LIMIT と OFFSET で実装も簡単です。
ただし落とし穴があります: データセットがリクエスト間で静止していることを前提にしている点です。実際のアプリでは新しい行の挿入、行の削除、ソートキーの変化が起きます。そこに「謎のバグ」が発生します。
オフセットページネーションはリストが動かないことを仮定しています。しかし現実のリストは動きます。リストがずれると「20をスキップ」といったオフセットは同じ項目を指さなくなります。
例を考えます。created_at desc(新しい順)でソートし、ページサイズは3だとします。
最初に offset=0, limit=3 でページ1を読み込み、[A, B, C] を得ます。
そこへ新しい項目 X が作られて先頭に入るとリストは [X, A, B, C, D, E, F, ...] になります。ページ2を offset=3, limit=3 で読み込むと、サーバは [X, A, B] をスキップして [C, D, E] を返します。
これで C を重複して見てしまい(重複)、後である項目を見逃すことになります(欠落)。
削除では逆の失敗が生じます。元が [A, B, C, D, E, F, ...] でページ1が [A, B, C] のとき、途中で B が削除されてリストが [A, C, D, E, F, ...] になると、ページ2の offset=3 は [A, C, D] をスキップして [E, F, G] を返します。D は二度と取得されないギャップになります。
新しい順のフィードでは先頭に挿入が起きやすく、後続のすべてのオフセットをズラすため特に問題です。
「安定したリスト」とはユーザーが期待するものです: 前方にスクロールする間、項目が飛んだり繰り返したり、不当に消えたりしないこと。時間を固定することではなく、ページングが予測可能であることが重要です。
しばしば混同される2つの概念があります:
created_at とタイブレーカーの id)、同じ入力なら同じ順序で返されること。リフレッシュとスクロールでの前方移動は別物です。リフレッシュは「今の新着を見せて」を意味するので先頭は変わって構いません。スクロール前方は「ここから続けて」を意味するので、シフトによる重複や欠落は避けるべきです。
多くのページネーションバグを防ぐ単純なルール: スクロール前方で重複を表示してはならない。
カーソルページネーションはページ番号の代わりにブックマークでリストを進めます。「3ページ目をください」ではなく「ここから続けてください」とクライアントが言います。
契約(contract)はシンプルです:
カーソルはソート順の位置にアンカーを打つので、挿入や削除に対してオフセットより耐性があります。
絶対に必要なのは決定的なソート順です。ソートルールと一貫したタイブレーカーがないと、カーソルは信頼できるブックマークになりません。
まず、人がそのリストをどう読むかに合うソート順を選んでください。フィードやメッセージ、アクティビティログは通常新しい順(newest first)が多く、請求書や監査ログは古い順が扱いやすいことが多いです。
カーソルはその順序内の位置を一意に特定しなければなりません。同じカーソル値を複数の項目が共有すると、重複やギャップが発生します。
よく使われる選択肢と注意点:
created_at のみ: 単純だが、タイムスタンプが衝突する行が多いと安全でない。id のみ: ID が単調増加するなら安全だが、望むプロダクト上の順序と合わないことがある。created_at + id: 通常は最良の組み合わせ(見た目の順序に created_at、タイブレークに id)。updated_at にする: 編集によって項目がページ間を移動するため、無限スクロールには危険。複数のソートオプションを提供する場合は、各ソートモードを別個のリストと扱い、それぞれに固有のカーソルルールを作ってください。カーソルは正確に1つの並び順でのみ意味を持ちます。
API の表面は小さくて済みます: 入力は2つ、出力は2つ。
limit(取得したい件数)とオプションの cursor(どこから続けるか)を送ります。カーソルがなければサーバは最初のページを返します。
リクエスト例:
GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
項目と next_cursor を返します。次ページがなければ next_cursor: null を返してください。クライアントはカーソルをトークンとして扱い、編集してはなりません。
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
サーバ側のロジック(平易な言葉): 決定的な順序でソートし、カーソルでフィルタをかけ、その後に limit を適用する。
たとえば (created_at DESC, id DESC) でソートする場合、カーソルを (created_at, id) にデコードし、カーソルペアより小さい(より古い)行を取得するようにフィルタして同じ順序で limit 件取ります。
カーソルは base64 の JSON ブロブにする(簡単)か、署名/暗号化トークンにする(手間)かを選べます。不透明にしておくと内部を後から変えやすく、互換性の破壊を避けられます。
また現実的なデフォルトを設定してください: モバイルのデフォルトは多くの場合 20–30、ウェブは 50、そしてサーバ側で上限を設けてバグったクライアントが一度に 10,000 行要求できないようにします。
安定したフィードは主に1つの約束に関わります: ユーザーが前方にスクロールし始めたら、他の誰かの作成・削除・編集で未表示の項目が飛び回ってはいけない。
カーソルページネーションでは挿入が最も扱いやすいです。新しいレコードはリフレッシュで表示されるべきで、既に読み込んだページの途中に入ってきて既存の項目を再配置するべきではありません。created_at DESC, id DESC でソートすれば新しい項目は自然に先頭側に入り、既存のカーソルは古い側へ続きます。
削除はリストを再配置させるべきではありません。削除された項目は単に返されなくなります。ページサイズを厳密に保つ必要があるなら、表示されるアイテムが limit 件になるまでさらに読み続ける実装にしてください。
編集は誤ってバグを再導入しやすい箇所です。重要な問いは「編集でソート位置が変わるか?」です。
スナップショット型の振る舞いはスクロールリストには通常ベストです: created_at のような不変のキーでページングします。編集は内容を変えるかもしれませんが項目自体は位置を移動しません。
ライブフィード型は edited_at のようにソートすると、古い項目が編集されて上位に移動するためジャンプが発生します。これを選ぶ場合はリフレッシュ指向の UX にして、常時変化するリストを許容する設計にしてください。
カーソルを「この行を見つける」ことに依存させないでください。最後に返した項目の {created_at, id} のような値で位置をエンコードしておけば、次のクエリは行存在に依存せず値ベースで行えます:
WHERE (created_at, id) < (:created_at, :id)id)を常に含めることで重複を避ける前方ページングは簡単な方です。より難しい UX の問いは後方ページング、リフレッシュ、ランダムアクセスです。
後方ページングでは次の2つのアプローチがよく使われます:
next_cursor(古い方向)と prev_cursor(新しい方向)を返しつつ、画面上のソート順は一貫させる。カーソルでは「ページ20」のようなランダムジャンプは難しいです。どうしてもジャンプが必要ならページインデックスではなく「このタイムスタンプの周辺」や「このメッセージ id の周辺」などのアンカーへジャンプし、そこからカーソルでページングしてください。
モバイルではキャッシュが重要です。カーソルは(クエリ+フィルタ+ソート)というリスト状態ごとに保存し、それぞれのタブやビューを独立したリストとして扱うと「タブを切り替えたら全部ぐちゃぐちゃ」になるのを防げます。
多くのカーソル問題はデータベース自体ではなく、リクエスト間の小さな不整合から生まれ、実際のトラフィックで顕在化します。
主な原因:
created_at のみ)とタイが発生して重複や欠落が起きるnext_cursor を返しているKoder.ai のようなプラットフォーム上でアプリを構築すると、ウェブとモバイルが同じエンドポイントを共有するためこれらのエッジケースは早く出ます。1つの明確なカーソル契約と1つの決定的な並び順ルールを持てば両クライアントは一貫します。
ページネーションを「完了」と言う前に、挿入・削除・リトライの条件下で挙動を検証してください。
next_cursor は実際に返した最後の行から取っているlimit に安全な上限と文書化されたデフォルトがあるリフレッシュについては1つの明確なルールを選んでください:ユーザーがプルして新しい項目を先頭に取得するのか、あるいは「最初の項目より新しいものがあるか?」を定期的に確認して「新しい項目」ボタンを示すのか。整合性があることが、リストを「不気味」ではなく「安定している」と感じさせます。
サポートの受信箱を想像してください。エージェントはウェブで見て、マネージャーはモバイルで同じ受信箱を確認します。並び順は新しい順。期待は1つ: 前方にスクロールしても項目が飛んだり、繰り返したり、消えたりしないこと。
オフセットページネーションではエージェントが1ページ目(1–20)を読み、次に offset=20 でページ2 を読み込む間に2件の新しいメッセージが先頭に入ると、offset=20 は元の位置と異なります。結果としてユーザーは重複を見たりメッセージを見逃したりします。
カーソルページネーションでは、アプリは「このカーソルの後の次の20件」を要求します。カーソルはユーザーが実際に見た最後の項目(通常 (created_at, id))に基づいているため、新しいメッセージがいつ来ても次ページはユーザーが見た最後のメッセージの直後から始まります。
出荷前に簡単にテストする方法:
プロトタイプを素早く作るなら、Koder.ai はチャットプロンプトからエンドポイントとクライアントフローの雛形を作るのに役立ちます。Planning Mode とスナップショット/ロールバックを使えば、ページネーションの変更がテストで問題を起こしたときに安全に繰り返しできます。
オフセットページネーションは「N行をスキップする」方式なので、新しい行が挿入されたり古い行が削除されたりすると行番号がずれます。同じオフセットが前と違う行を指すようになり、スクロール中に重複や欠落が発生します。
カーソルページネーションは「最後に見た項目の後ろから続ける」というブックマークを使います。次のリクエストはその位置から決定的な順序で続くため、先頭への挿入や途中の削除がオフセットのようにページ境界を動かしません。
タイブレーカーを含む決定的なソートを使ってください。多くの場合は (created_at, id) が最も実用的です。created_at が期待される順序を与え、id がタイムスタンプの衝突を避けることで位置を一意にします。
updated_at でソートすると編集によって項目がページ間を移動しやすくなり、「スクロール中に安定して進む」という期待を裏切ります。編集による再配置が必要な場合は、UI をリフレッシュ指向にして並び替えの再現を許容する設計にしてください。
サーバは next_cursor として不透明なトークンを返し、クライアントはそれをそのまま送り返します。簡単な方法は最後に返した項目の (created_at, id) を base64 した JSON にすることですが、内部を変更できるようにクライアントには不透明な値として扱わせることが重要です。
カーソルは「この行そのものを見つける」ことに依存させないでください。最後に返した項目の値(例:created_at と id)をエンコードしておけば、その行が削除されていてもその値で位置を定義できます。クエリは行の存在ではなく値に基づいて行います。
厳密比較と一意のタイブレーカーを使い、next_cursor は実際に返した最後の行から生成してください。繰り返しが起きる主な原因は <= の使い忘れ(< とすべきところで <= を使う)、タイブレーカーの省略、あるいは next_cursor を誤った行から作ることです。
明確なルールを1つ決めてください:リフレッシュは先頭の新しい項目を読み込むためのもの、スクロール前方は既存のカーソルから古い項目へ進むためのものです。同じカーソルフローにリフレッシュの意味を混ぜると、ユーザーは並び替えが起きてリストが信頼できないと感じます。
カーソルは特定の並び順とフィルタの組み合わせにのみ有効です。並び替えや検索クエリ、フィルタを変えたら新しいページネーションセッションを開始し、カーソルはそのリスト状態ごとに別々に管理してください。
カーソルは順次参照に優れますが「20ページ目」のようなランダムアクセスには向きません。どうしても飛びたい場合は「このタイムスタンプの周辺」や「この id の後から始める」などのアンカーでジャンプし、そこからカーソルでページングするのが現実的です。