Курсорная пагинация сохраняет стабильность списков при изменениях данных. Узнайте, почему offset ломается при вставках и удалениях и как реализовать надёжные курсоры.

Вы открываете фид, скроллите немного, и всё кажется нормальным — пока не становится не так. Вы видите один и тот же элемент дважды. Что‑то, что вы точно видели, исчезает. Строка, по которой вы собирались нажать, смещается, и вы попадаете не туда.
Это баги, заметные пользователям, даже если отдельные ответы API выглядят «правильными». Обычные симптомы легко распознать:
На мобильных устройствах это становится ещё хуже. Пользователь может поставить приложение на паузу, переключиться в другое, потерять связь и продолжить позже. За это время появляются новые элементы, старые удаляются, некоторые редактируются. Если приложение продолжает спрашивать «страница 3» с помощью offset, границы страниц могут сместиться, пока пользователь в процессе прокрутки. В результате фид кажется нестабильным и ненадёжным.
Цель простая: когда пользователь начинает скроллить дальше, список должен вести себя как снимок. Новые элементы могут появляться, но они не должны переставлять элементы, которые пользователь уже просматривает. Пользователь должен получать плавную и предсказуемую последовательность.
Ни один метод пагинации не идеален. В реальных системах есть одновременные записи, правки и несколько опций сортировки. Но курсорная пагинация обычно безопаснее offset‑пагинации, потому что она переходит от конкретной позиции в стабильном порядке, а не от меняющегося счёта строк.
Offset‑пагинация — это способ «пропустить N, взять M». Вы говорите API, сколько строк пропустить (offset) и сколько вернуть (limit). С limit=20 вы получаете 20 элементов на страницу.
Концептуально:
GET /items?limit=20\u0026offset=0 (первая страница)GET /items?limit=20\u0026offset=20 (вторая страница)GET /items?limit=20\u0026offset=40 (третья страница)Ответ обычно включает элементы и достаточно информации, чтобы запросить следующую страницу.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Этот подход популярен, потому что хорошо ложится на таблицы, админ‑списки, результаты поиска и простые фиды. Его легко реализовать в SQL с LIMIT и OFFSET.
Подводный камень — скрытое предположение: набор данных не меняется между запросами. В реальных приложениях список движется: добавляются новые строки, строки удаляются, ключи сортировки меняются. Вот где появляются «таинственные баги».
Offset‑пагинация предполагает, что список стабилен между запросами. Но в реальности список меняется. Когда список сдвигается, offset вроде «пропустить 20» уже не указывает на те же элементы.
Представьте фид, отсортированный по created_at desc (сначала новые), размер страницы 3.
Вы загружаете страницу 1 с offset=0, limit=3 и получаете [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, ...]. Вы загрузили первую страницу и увидели [A, B, C]. Перед загрузкой страницы 2 B удалили, список стал [A, C, D, E, F, ...]. Страница 2 с offset=3 пропускает [A, C, D] и возвращает [E, F, G]. D ускользает и никогда не запрашивается.
В фидах «сначала новые» вставки происходят вверху, а это как раз и сдвигает все последующие offsetы.
«Стабильный список» — это то, что ожидают пользователи: при прокрутке вперед элементы не скачут, не повторяются и не исчезают без причины. Речь не о заморозке времени, а о предсказуемой пагинации.
Часто путают две идеи:
created_at с tie‑breaker‑ом id), так что при одинаковых входных данных два запроса вернут тот же порядок.Обновление (refresh) и прокрутка вперед — разные действия. Обновление означает «покажи, что нового сейчас», поэтому верх может измениться. Прокрутка вперед означает «продолжай оттого места, где я был», поэтому не должно быть повторов или неожиданных пробелов из‑за смещения границ страниц.
Простое правило, предотвращающее большинство багов: прокрутка вперед никогда не должна показывать повторы.
Курсорная пагинация перемещается по списку с помощью закладки, а не номера страницы. Вместо «дай мне страницу 3» клиент говорит «продолжай отсюда».
Контракт прост:
Это лучше переносит вставки и удаления, потому что курсор привязан к позиции в отсортированном списке, а не к счёту строк.
Обязательное требование — детерминированный порядок сортировки. Нужен стабильный порядок и консистентный tie‑breaker, иначе курсор не будет надёжной закладкой.
Сначала выберите один порядок сортировки, соответствующий тому, как люди читают список. Фиды, сообщения и логи активности обычно идут от новых к старым. Истории вроде счетов и аудита часто удобнее смотреть от старых к новым.
Курсор должен однозначно идентифицировать позицию в этом порядке. Если два элемента могут иметь одно и то же значение курсора, в конечном итоге вы получите дубликаты или пробелы.
Типичные варианты и на что обратить внимание:
created_at: просто, но опасно, если много строк имеют одинаковую метку времени.id: безопасно, если id монотонно растут, но может не соответствовать желаемому порядку продукта.created_at + id: обычно лучший баланс (время для порядка продукта, id как tie‑breaker).updated_at как первичный сорт: рискованно для бесконечного скролла, потому что правки могут перемещать элементы между страницами.Если вы предлагаете несколько режимов сортировки, рассматривайте каждый режим как отдельный список с собственными правилами курсора. Курсор имеет смысл только для одного точного порядка.
Можно оставить поверхность API маленькой: два входа, два выхода.
Отправьте limit (сколько элементов хотите) и опциональный cursor (откуда продолжить). Если курсора нет, сервер возвращает первую страницу.
Пример запроса:
GET /api/messages?limit=30\u0026cursor=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), затем выбирайте строки, где (created_at, id) строго меньше пары курсора, применяйте тот же порядок и берите limit строк.
Вы можете кодировать курсор как base64 JSON‑блоб (просто) или как подписанный/зашифрованный токен (сложнее). Непрозрачный формат безопаснее, потому что позволяет поменять внутренности позже без ломки клиентов.
Также задайте разумные значения по умолчанию: мобильный дефолт 20–30, веб‑дефолт часто 50, и жёсткий серверный максимум, чтобы один багованный клиент не мог запросить 10,000 строк.
Стабильный фид — это в основном одно обещание: как только пользователь начинает скроллить вперед, элементы, которые он ещё не видел, не должны прыгать из‑за того, что кто‑то другой создал, удалил или отредактировал записи.
С курсорной пагинацией вставки — самые простые. Новые записи должны появляться при обновлении, но не посреди уже загруженных страниц. Если вы сортируете по created_at DESC, id DESC, новые элементы естественно находятся перед первой страницей, поэтому существующий курсор продолжает в сторону более старых элементов.
Удаления не должны перестраивать список. Если элемент удалён, он просто не вернётся при следующем запросе. Если нужно поддерживать постоянный размер страниц, продолжайте запрашивать, пока не соберёте limit видимых элементов.
Правки — место, где команды случайно снова вызывают баги. Ключевой вопрос: может ли правка изменить позицию в сортировке?
Поведение «снимка» обычно лучше для прокрутки: страничьте по неизменяемому ключу вроде created_at. Правки могут менять содержимое, но элемент не перескакивает в новую позицию.
Поведение «живого» фида сортирует по чему‑то вроде edited_at. Это может вызвать скачки (старый элемент отредактировали — он поднимается вверх). Если вы выбираете это, проектируйте UX под постоянную смену порядка и делайте обновления явными.
Не делайте курсор зависимым от «найди эту конкретную строку». Кодируйте позицию, например {created_at, id} последнего возвращённого элемента. Тогда следующий запрос строится по значениям, а не по существованию строки:
WHERE (created_at, id) < (:created_at, :id)id) чтобы избежать повторовПагинация вперед — простая часть. Более хитрые UX‑вопросы — это пагинация назад, обновление и случайный доступ.
Для пагинации назад обычно работают два подхода:
next_cursor для старых и prev_cursor для новых) при сохранении одного порядка на экране.Прыжки по страницам с курсорами сложнее: «страница 20» не имеет стабильного смысла, когда набор данных меняется. Если нужен настоящий прыжок, переходите к якорю вроде «вокруг этого timestamp» или «начиная с этого id», а не по индексу страницы.
На мобильных клиентах кэширование важно. Храните курсоры по состоянию списка (запрос + фильтры + сорт), и рассматривайте каждую вкладку/вид как отдельный список. Это предотвращает «переключил вкладку — и всё перепуталось».
Большинство проблем с курсорной пагинацией — не в базе данных, а в небольших несоответствиях между запросами, которые проявляются только при реальном трафике.
Главные виновники:
created_at), из‑за чего при совпадениях возникают дубликаты или пропуски.next_cursor, который не соответствует последнему реально возвращённому элементу.Если вы строите приложения на платформах вроде Koder.ai, эти крайние случаи быстро проявятся, потому что веб‑ и мобильные клиенты часто используют один и тот же endpoint. Один явный контракт курсора и одна детерминированная сортировка держат обоих клиентов в согласии.
Прежде чем называть пагинацию «готовой», проверьте поведение при вставках, удалениях и повторах запросов.
next_cursor берётся из последнего возвращённого рядаlimit имеет безопасный максимум и документированный дефолтДля обновления выберите одно правило: либо пользователь тянет для обновления, чтобы получить новые элементы вверху, либо вы периодически проверяете «есть ли что‑то новее моего первого элемента?» и показываете кнопку «Новые элементы». Последовательность делает список стабильным, а не «преследуемым привидениями».
Представьте inbox поддержки, которым агенты пользуются в вебе, а менеджер проверяет тот же inbox на мобильном. Список отсортирован по новым вначале. Ожидание одно: при прокрутке вперед элементы не должны прыгать, повторяться или исчезать.
С offset‑пагинацией агент загружает страницу 1 (элементы 1–20), затем скроллит к странице 2 (offset=20). Пока он читает, приходят два новых сообщения наверх. Теперь offset=20 указывает не на то же место, что и секунду назад. Пользователь видит повторы или пропускает сообщения.
С курсорной пагинацией приложение запрашивает «следующие 20 элементов после этого курсора», где курсор основан на последнем фактически увиденном элементе (обычно (created_at, id)). Новые сообщения могут приходить весь день, но следующая страница всё равно начнётся сразу после последнего сообщения, которое видел пользователь.
Простой способ протестировать перед релизом:
Если прототипируете быстро, Koder.ai поможет создать endpoint и клиентские потоки из чат‑промпта, затем итеративно улучшать их с помощью Planning Mode, снимков состояния и отката, когда изменение пагинации удивит вас в тестировании.
Пагинация через offset говорит «пропусти N строк», поэтому при добавлении новых строк или удалении старых счетчик сдвигается. Один и тот же offset может начать указывать на другие элементы, чем раньше — отсюда повторы и пробелы при прокрутке.
Курсорная пагинация использует закладку, которая представляет «позицию после последнего увиденного элемента». Следующий запрос продолжается с этой позиции в детерминированном порядке, поэтому вставки в начало и удаления в середине не сдвигают границу страницы так, как это делает offset.
Используйте детерминированную сортировку с tie‑breaker, чаще всего (created_at, id) в одном направлении. created_at задает удобный для продукта порядок, а id делает каждую позицию уникальной, чтобы не повторять и не пропускать элементы при совпадении меток времени.
Сортировка по updated_at может привести к тому, что элементы будут перескакивать между страницами при редактировании, что ломает ожидание «стабильной прокрутки вперед». Если нужен «живой» вид по последним обновлениям, спроектируйте UX с явным обновлением и примите пересортировку.
Возвращайте непрозрачный токен как next_cursor и требуйте, чтобы клиент отправлял его обратно без изменений. Простой вариант — закодировать (created_at, id) последнего элемента в base64‑JSON, но главное — трактовать курсор как opaque‑значение, чтобы вы могли менять внутренности позже.
Постройте следующий запрос по значениям курсора, а не по «найди эту строку». Если последний элемент удалён, сохраненные (created_at, id) всё ещё определяют позицию, и вы можете продолжать с фильтром «строго меньше» (или «строго больше») в том же порядке.
Используйте строгое сравнение и уникальный tie‑breaker, и всегда берите курсор из последнего реально возвращённого ряда. Большинство багов с повторами появляются из‑за <= вместо <, отсутствия tie‑breaker или генерации next_cursor по неверной строке.
Выберите одно ясное правило: обновление загружает более новые элементы вверху, а прокрутка вперед продолжает в сторону старых элементов от существующего курсора. Не смешивайте семантику «обновления» и поток курсора, иначе пользователи увидят пересортировку и подумают, что список ненадежен.
Курсор действителен только для одного точного порядка и набора фильтров. Если клиент меняет режим сортировки, поисковый запрос или фильтры, он должен начать новую сессию пагинации без курсора и хранить курсоры отдельно для каждого состояния списка.
Курсор отлично подходит для последовательного просмотра, но не для стабильных «страниц 20», потому что дата‑сет меняется. Если нужен переход, переходите к якорю, например «вокруг этого timestamp» или «начиная после этого id», а затем пагинируйте курсорами оттуда.