Пагінація здається дрібницею, поки даних мало. Але як тільки таблиця виростає до сотень тисяч або мільйонів рядків, пагінація стає одним із головних джерел повільності й “дивної” поведінки. Є два базові підходи: offset pagination (класична “сторінка 1/2/3” через OFFSET) і cursor pagination (перегортання “далі/назад” через курсор — значення останнього елемента). Важливо розуміти, що вони вирішують різні задачі, і вибір впливає на швидкість, стабільність результатів і UX.
Offset-пагінація — це те, що ви бачите майже всюди за замовчуванням: page=10 означає “пропусти перші 9 сторінок і покажи наступні 50”. На рівні SQL це LIMIT 50 OFFSET 450. Перевага очевидна: можна перейти на конкретну сторінку, можна показати “сторінка 1 з 200”, можна порахувати загальну кількість. Саме тому вона зручна для адмінки, де люди реально користуються “перейти на сторінку N” і їм важливо бачити total.
Але у offset є фундаментальна проблема продуктивності: чим далі сторінка, тим дорожче. Базі потрібно “пройти” багато рядків, щоб дійти до потрібного offset. Навіть якщо індекс є, глибокий OFFSET змушує базу робити зайву роботу. На сторінці 1 усе швидко. На сторінці 500 — вже помітно. На сторінці 5000 — боляче. У реальних стрічках і логах це стає блокером.
Друга проблема offset-пагінації — нестабільність результатів при змінах даних. Якщо між вашими запитами з’явилися нові записи або змінився порядок сортування, записи можуть “перескочити” між сторінками: ви бачите дублікати або пропуски. Це особливо видно у стрічках новин, де постійно додаються записи, і у логах активності.
Cursor-пагінація працює інакше. Ви не кажете “пропусти N рядків”. Ви кажете “дай наступні 50 після цього конкретного елемента”. Тобто курсор — це мітка позиції в упорядкованому наборі. Зазвичай курсор будується з поля сортування (наприклад, published_at) і унікального tiebreaker (наприклад, id). На рівні SQL це виглядає як WHERE (published_at, id) < (:lastPublishedAt, :lastId) ORDER BY published_at DESC, id DESC LIMIT 50. База може дуже ефективно використати індекс і одразу “приземлитися” в потрібне місце без проходу тисяч рядків.
Головна перевага cursor — стабільна швидкість на будь-якій “глибині”. Ви можете перегорнути 10 000 сторінок “далі”, і кожен запит буде приблизно однаково швидким (за умови правильного індексу). Друга перевага — менше “скачків” і дублікатів при додаванні нових записів, якщо курсор побудований правильно, бо ви рухаєтесь від конкретної точки, а не від “умовного номера сторінки”.
Але cursor має обмеження, які часто критичні для адмінки. Перше — зазвичай немає “перейти на сторінку 123”. Є “далі/назад” по курсору. Друге — складно/дорого показувати “з 1 000 000 записів”, бо cursor не орієнтований на total count. Третє — курсор вимагає стабільного, детермінованого сортування. Ви маєте завжди сортувати за фіксованим набором полів і мати унікальний tiebreaker, інакше курсор може давати пропуски/дублікати.
Тому правильний вибір залежить від сценарію. Для стрічки новин, логів, activity feed, нескінченного скролу, API для мобільного додатку — cursor майже завжди кращий. Він швидкий і стабільний на великій глибині. Для адмінки, де потрібні “сторінки”, фільтри, total count, і де глибоке листання не таке часте — offset залишається нормальним вибором, але з умовою: ви не повинні допускати “гігантського” листання на тисячі сторінок як основний UX. Якщо даних багато — адмінку краще змушувати фільтрувати, а не листати без кінця.
Є ще одна важлива деталь: cursor-пагінація найкраще працює, коли ваш ORDER BY відповідає індексу. Наприклад, якщо ви сортуєте за published_at desc, id desc, то індекс по (published_at, id) дає максимальний ефект. Якщо ви дозволяєте користувачу сортувати “як завгодно” (по назві, по автору, по статусу), cursor стає складнішим: під кожне сортування потрібна своя стратегія і часто свій індекс. Тому cursor добре поєднується з “стрічками”, де порядок майже завжди один — за датою.
Концептуальна рекомендація “на майбутнє” для вашого типу проєкту така. Для публічних стрічок/архівів/логів активності, де даних дуже багато і потрібна стабільна швидкість — плануйте cursor. Для адмінських списків з фільтрами, де важливо бачити загальну кількість і переходити по сторінках — offset, але з правильною індексацією і дисципліною фільтрів. Якщо помітили, що offset став повільним через глибокі сторінки, це не привід одразу “переписати на cursor”, це привід змінити UX: додати фільтри, обмежити глибину або додати “перейти за датою/ID”.
Висновок: offset-пагінація зручна для адмінки і “сторінок”, але дорожчає з глибиною і може давати нестабільні результати при активних змінах даних. Cursor-пагінація швидка й стабільна на великих обсягах, але гірше підходить для випадків, де потрібні номер сторінки і total count. Вибір робиться не “яка модніша”, а під конкретний сценарій і майбутній розмір даних.