Бесконечная лента против пагинации: когда что выбрать
Бесконечный скролл кажется современнее, но ломает возврат к товару, глубокую ссылку и SEO. Разбираем, какой подход к списку выбрать под конкретную задачу и чем платит каждый.

Пользователь пролистал ленту до 400-го элемента, кликнул по карточке, нажал «назад» — и оказался в начале списка. Ему заново листать четыре сотни позиций. Это не абстрактная проблема UX, а конкретный симптом того, что для этой задачи выбрали не тот способ подгрузки. Бесконечный скролл, курсорная пагинация и классические страницы — три разных инструмента, и путаница между ними стоит либо потерянных конверсий, либо переписанного бэкенда через полгода.
Разберём, чем каждый подход платит и по какому признаку выбирать. Про виртуализацию длинных списков (когда в DOM держат не все элементы, а только видимые) скажу отдельной строкой в конце — это ортогональная тема, её можно наложить на любой из трёх подходов.
Три подхода и что у них под капотом
Классическая пагинация по номеру страницы
Запрос вида ?page=3&limit=20. Бэкенд переводит это в LIMIT 20 OFFSET 40. Просто, предсказуемо, каждая страница адресуема ссылкой. Знаете, сколько всего элементов — можете показать «страница 3 из 25».
Проблема прячется в OFFSET. Чтобы вернуть строки с 10 000-й по 10 020-ю, база проматывает и отбрасывает первые 10 000. На больших смещениях это линейно деградирует. Вторая беда — сдвиг данных: если между загрузкой страницы 2 и страницы 3 кто-то вставил запись в начало, один элемент вы увидите дважды, а другой пропустите.
Курсорная (keyset) пагинация
Вместо номера страницы клиент передаёт «указатель» на последний виденный элемент: ?after=eyJpZCI6MTIzfQ. Бэкенд строит запрос не через OFFSET, а через условие сравнения по индексированному полю — например WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20. База использует индекс и не проматывает пропущенное, поэтому 10 000-я «страница» стоит столько же, сколько первая.
Цена: нельзя прыгнуть на произвольную страницу и трудно показать общий счётчик страниц. Курсор должен строиться по стабильно упорядоченному ключу — сортировка по неуникальному полю без добавления id в качестве тай-брейкера даёт пропуски на границах.
Бесконечный скролл
Это не способ хранения данных на бэкенде, а паттерн интерфейса: новая порция подгружается автоматически при приближении к концу списка. Под ним обычно лежит именно курсорная пагинация — по номеру страницы бесконечную ленту делать больно из-за сдвига данных.
Триггер подгрузки в браузере сегодня делают через IntersectionObserver — он вызывает колбэк, когда «сторож» в конце списка попадает в область видимости, без слушателей на scroll и ручных расчётов позиции.
Бесконечный скролл — решение для потребления контента, а не для поиска конкретного элемента. Как только пользователю нужно вернуться к найденному, паттерн начинает работать против него.
Чем платит каждый подход
| Критерий | Пагинация по номеру | Курсорная | Бесконечный скролл |
|---|---|---|---|
| Прыжок на произвольную страницу | Есть | Нет | Нет |
| Общий счётчик страниц | Легко | Дорого/нет | Обычно нет |
| Стоимость глубокой позиции | Растёт с offset | Постоянная | Постоянная |
| Возврат к позиции по «назад» | Есть из коробки | Есть (позиция в URL) | Требует ручной работы |
| Устойчивость к вставке данных | Низкая | Высокая | Высокая |
| Индексируемость поисковиком | Хорошая | Средняя | Плохая без fallback |
| Доступность (клавиатура, скринридер) | Проще | Проще | Требует внимания |
Когда что выбирать
- Каталог товаров, где важен возврат к найденному и SEO — пагинация по номеру или гибрид (см. ниже). Пользователь и поисковый робот должны попадать по ссылке на ту же выборку.
- Лента новостей, соцсеть, чат, бесконечно растущий фид — бесконечный скролл на курсорной основе. Здесь нет «страницы 40», есть поток, и вставка свежих записей сверху не должна ломать листание.
- API с большими списками, синхронизация, экспорт — только курсорная.
OFFSETна глубине убьёт базу, а прыжки по страницам клиенту не нужны. - Админка, таблицы, отчёты — пагинация по номеру. Оператору нужно «страница 3 из 12», сортировка по колонкам и точный счётчик.
Гибрид: URL решает половину боли
Главная претензия к бесконечному скроллу — потеря позиции при возврате. Её лечат синхронизацией текущего курсора или номера порции с адресной строкой. При подгрузке очередной порции пишете в URL параметр (через history.replaceState, чтобы не плодить записи в истории), а при заходе по ссылке восстанавливаете уже загруженные порции до нужной позиции.
Второй частый приём — кнопка «Показать ещё» вместо автоматической подгрузки. Технически это та же курсорная пагинация, но подгрузка по явному клику: пользователь контролирует момент, футер сайта остаётся достижимым (при бесконечной автоподгрузке до него невозможно долистать), а скринридер не теряет фокус.
Курсор
Указатель на последний обработанный элемент. Обычно это закодированное значение поля сортировки последней записи (и уникального ключа), по которому строится условие выборки следующей порции. Не путать с номером страницы: курсор описывает «где мы остановились», а не «какая по счёту страница».
Что ломается на практике
- Сортировка по неуникальному полю без тай-брейкера. Сортируете курсорную выборку только по
created_at, а у двух записей одинаковое время — на границе порции получите дубль или пропуск. Всегда добавляйте уникальный ключ вторым полем сортировки и в курсор. - Дубли при бесконечном скролле и вставке сверху. Если фид растёт, а вы дедуплицируете порции по индексу массива, а не по id элемента — при перекрытии появятся повторы. Дедупликация только по стабильному идентификатору.
- Двойной триггер
IntersectionObserver. Наблюдатель может сработать дважды подряд до того, как пришёл ответ. Блокируйте повторный запрос флагом «идёт загрузка», иначе получите две одинаковые порции. - Забытое состояние «загрузок больше нет». Бэкенд вернул пустую или неполную порцию, а клиент продолжает дёргать запрос при каждом скролле. Останавливайте наблюдатель, когда пришло меньше элементов, чем запрашивали, или когда сервер явно сообщил об отсутствии следующего курсора.
- Скролл ломает «назад» в браузере. Если писать позицию через
history.pushStateна каждую порцию, кнопка «назад» будет прокручивать порции внутри одной страницы, а пользователь ждёт возврата на предыдущий экран. Для позиции внутри ленты используйтеreplaceState.
Мини-скелет курсорной подгрузки
Схематично, ванильный JS, современный браузер с поддержкой IntersectionObserver и fetch. Обвязку под ваш фреймворк не привожу — важна логика защиты от повторов и остановки.
let cursor = null; // курсор последней порции, null = с начала
let loading = false; // защита от двойного запроса
let finished = false; // больше грузить нечего
const list = document.querySelector('#list');
const sentinel = document.querySelector('#sentinel'); // пустой div в конце
async function loadNext() {
if (loading || finished) return;
loading = true;
const url = new URL('/api/items', location.origin);
url.searchParams.set('limit', '20');
if (cursor) url.searchParams.set('after', cursor);
const res = await fetch(url);
const data = await res.json(); // { items: [...], nextCursor: '...' | null }
for (const item of data.items) {
const li = document.createElement('li');
li.dataset.id = item.id; // для дедупликации по id, а не по индексу
li.textContent = item.title;
list.appendChild(li);
}
cursor = data.nextCursor;
// порция меньше лимита или сервер не дал курсор -> конец
if (!data.nextCursor || data.items.length < 20) {
finished = true;
observer.disconnect();
}
loading = false;
}
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadNext();
}, { rootMargin: '200px' }); // грузим заранее, за 200px до края
observer.observe(sentinel);Ключевые места: флаги loading и finished, дедупликация по data-id, rootMargin для упреждающей загрузки и observer.disconnect() при исчерпании данных.
Что делать дальше
Определитесь с приоритетом: если пользователю и поисковику важно попадать по ссылке в конкретную выборку — стройте вокруг URL и склоняйтесь к пагинации по номеру или гибриду с «Показать ещё». Если это растущий фид — курсорная основа плюс бесконечный скролл, но с записью позиции в адрес.
На бэкенде проверьте план запроса на глубоких выборках: EXPLAIN ANALYZE на курсорном условии должен показывать index scan, а не seq scan. Если стоит OFFSET и на глубине запрос уходит в сотни миллисекунд — это первый кандидат на переход к keyset. И наложите виртуализацию списка (рендер только видимых элементов), если в DOM накапливаются тысячи узлов — тормозить начнёт уже браузер, а не сеть; это отдельная задача поверх любого из трёх подходов.
Защитник прав потребителей: Досудебные претензии на возврат товара со ссылками на ЗоЗПП.
Спросить за 15 ₽Источники: MDN — Intersection Observer API, MDN — History.replaceState()
Частые вопросы
У меня старый проект без IntersectionObserver, скролл считается вручную. Стоит переписывать?
Курсорная подгрузка выдаёт дубли на границах порций. Что не так?
Бесконечный скролл вредит SEO? Робот не увидит подгруженные элементы?
Как показать «страница X из Y» при курсорной пагинации?
Пользователь жалуется, что не может долистать до футера сайта. Это баг?
OFFSET на большой странице тормозит. Достаточно добавить индекс?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.