KoderKoder.ai
料金エンタープライズ教育投資家向け
ログインはじめる

プロダクト

料金エンタープライズ投資家向け

リソース

お問い合わせサポート教育ブログ

リーガル

プライバシーポリシー利用規約セキュリティ利用ポリシー不正利用を報告

ソーシャル

LinkedInTwitter
Koder.ai
言語

© 2026 Koder.ai. All rights reserved.

ホーム›ブログ›謎のバグを防ぐ安定したAPIリストのためのカーソルページネーション
2025年12月18日·1 分

謎のバグを防ぐ安定したAPIリストのためのカーソルページネーション

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

謎のバグを防ぐ安定したAPIリストのためのカーソルページネーション

問題: スクロール中にページが変わるリスト

フィードを開いて少しスクロールしていると、何かがおかしくなる瞬間があります。同じ項目が二度表示される。確かに見えたはずの項目が消える。タップしようとした行が下にずれて、意図しない詳細ページを開いてしまう。

こうした現象は API のレスポンスが個別に見れば「正しく」見えても、ユーザーにとっては明らかなバグです。よくある症状は次の通りです:

  • ページ間で項目が重複する
  • 一度も表示されなかった項目が欠ける
  • スクロール中に項目の位置が移動する
  • 無限スクロールが早めに止まる、あるいは同じページを読み込む

モバイルではさらに悪化します。人はアプリを一時停止したり、別のアプリに切り替えたり、接続が途切れたりしてから続けます。その間に新しい項目が入り、古い項目が消え、いくつかは編集されます。アプリがオフセットで「ページ3」を要求し続けると、ユーザーがスクロール中にページ境界がずれてしまいます。結果として、フィードは不安定で信用できないものに感じられます。

目標はシンプルです: ユーザーが前方にスクロールし始めたら、そのリストはスナップショットのように振る舞うべきです。新しい項目は存在しても、既にユーザーがページングしている内容を再配置してはいけません。ユーザーには滑らかで予測可能な順序を提供すること。

どのページング方法も完璧ではありません。現実のシステムには同時書き込み、編集、複数のソートオプションが存在しますが、カーソルページネーションは通常オフセットより安全です。なぜなら、行数の移動ではなく、順序上の特定の位置からページングするからです。

オフセットページネーションを1分で説明

オフセットページネーションは「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)はシンプルです:

  • API は項目の束と、最後の項目の後ろの位置を表すカーソルを返す。
  • クライアントは次のバッチを得るためにそのカーソルを返す。

カーソルはソート順の位置にアンカーを打つので、挿入や削除に対してオフセットより耐性があります。

絶対に必要なのは決定的なソート順です。ソートルールと一貫したタイブレーカーがないと、カーソルは信頼できるブックマークになりません。

カーソルとソート順の選び方

Earn credits while building
Share what you learn about Koder.ai and earn credits through the content program.
Earn Credits

まず、人がそのリストをどう読むかに合うソート順を選んでください。フィードやメッセージ、アクティビティログは通常新しい順(newest first)が多く、請求書や監査ログは古い順が扱いやすいことが多いです。

カーソルはその順序内の位置を一意に特定しなければなりません。同じカーソル値を複数の項目が共有すると、重複やギャップが発生します。

よく使われる選択肢と注意点:

  • created_at のみ: 単純だが、タイムスタンプが衝突する行が多いと安全でない。
  • id のみ: ID が単調増加するなら安全だが、望むプロダクト上の順序と合わないことがある。
  • created_at + id: 通常は最良の組み合わせ(見た目の順序に created_at、タイブレークに id)。
  • 主要ソートを updated_at にする: 編集によって項目がページ間を移動するため、無限スクロールには危険。

複数のソートオプションを提供する場合は、各ソートモードを別個のリストと扱い、それぞれに固有のカーソルルールを作ってください。カーソルは正確に1つの並び順でのみ意味を持ちます。

ステップバイステップ: シンプルで整ったカーソル API 形

API の表面は小さくて済みます: 入力は2つ、出力は2つ。

1) リクエスト: limit + cursor

limit(取得したい件数)とオプションの cursor(どこから続けるか)を送ります。カーソルがなければサーバは最初のページを返します。

リクエスト例:

GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==

2) レスポンス: items + next_cursor

項目と 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 件取ります。

3) カーソルのエンコード: 不透明にするのが勝ち

カーソルは base64 の JSON ブロブにする(簡単)か、署名/暗号化トークンにする(手間)かを選べます。不透明にしておくと内部を後から変えやすく、互換性の破壊を避けられます。

また現実的なデフォルトを設定してください: モバイルのデフォルトは多くの場合 20–30、ウェブは 50、そしてサーバ側で上限を設けてバグったクライアントが一度に 10,000 行要求できないようにします。

挿入、削除、編集の扱い

Ship stable infinite scroll
Build cursor pagination endpoints in Koder.ai from a chat prompt and iterate fast.
Try Free

安定したフィードは主に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つの決定的な並び順ルールを持てば両クライアントは一貫します。

リリース前のチェックリスト

Export the source code
Keep control by exporting your React, Go, and Flutter source code when you are ready.
Export Code

ページネーションを「完了」と言う前に、挿入・削除・リトライの条件下で挙動を検証してください。

  • ソートは明示的で決定的、かつタイブレーカーを含む
  • すべてのリクエストで同じフィルタとソートフィールドが再現される
  • 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) が最も実用的です。created_at が期待される順序を与え、id がタイムスタンプの衝突を避けることで位置を一意にします。

ライブフィードのために updated_at でページングしてもいいですか?

updated_at でソートすると編集によって項目がページ間を移動しやすくなり、「スクロール中に安定して進む」という期待を裏切ります。編集による再配置が必要な場合は、UI をリフレッシュ指向にして並び替えの再現を許容する設計にしてください。

カーソルページネーションの API レスポンスに何を含めるべきですか?

サーバは next_cursor として不透明なトークンを返し、クライアントはそれをそのまま送り返します。簡単な方法は最後に返した項目の (created_at, id) を base64 した JSON にすることですが、内部を変更できるようにクライアントには不透明な値として扱わせることが重要です。

カーソルで参照している項目が削除されたらどうなりますか?

カーソルは「この行そのものを見つける」ことに依存させないでください。最後に返した項目の値(例:created_at と id)をエンコードしておけば、その行が削除されていてもその値で位置を定義できます。クエリは行の存在ではなく値に基づいて行います。

次のページを取得するときに重複を避けるには?

厳密比較と一意のタイブレーカーを使い、next_cursor は実際に返した最後の行から生成してください。繰り返しが起きる主な原因は <= の使い忘れ(< とすべきところで <= を使う)、タイブレーカーの省略、あるいは next_cursor を誤った行から作ることです。

リフレッシュはカーソルページネーションでどう扱うべきですか?

明確なルールを1つ決めてください:リフレッシュは先頭の新しい項目を読み込むためのもの、スクロール前方は既存のカーソルから古い項目へ進むためのものです。同じカーソルフローにリフレッシュの意味を混ぜると、ユーザーは並び替えが起きてリストが信頼できないと感じます。

ユーザーがフィルタやソートを変えたとき同じカーソルを使えますか?

カーソルは特定の並び順とフィルタの組み合わせにのみ有効です。並び替えや検索クエリ、フィルタを変えたら新しいページネーションセッションを開始し、カーソルはそのリスト状態ごとに別々に管理してください。

カーソルで特定のページにジャンプしたりランダムアクセスできますか?

カーソルは順次参照に優れますが「20ページ目」のようなランダムアクセスには向きません。どうしても飛びたい場合は「このタイムスタンプの周辺」や「この id の後から始める」などのアンカーでジャンプし、そこからカーソルでページングするのが現実的です。

目次
問題: スクロール中にページが変わるリストオフセットページネーションを1分で説明挿入や削除でオフセットが壊れる理由ウェブとモバイルにおける「安定したリスト」とはカーソルページネーション: 基本の考え方カーソルとソート順の選び方ステップバイステップ: シンプルで整ったカーソル API 形挿入、削除、編集の扱い後方ページング、リフレッシュ、ランダムアクセス謎のバグを生むよくある間違いリリース前のチェックリスト現実的な例: 複数端末で安定する受信箱よくある質問
共有
Koder.ai
Koderで自分のアプリを作ろう 今すぐ!

Koderの力を理解する最良の方法は、自分で体験することです。

無料で始めるデモを予約