Cursor Pagination — це метод посторінкового завантаження даних, який використовує унікальний курсор для навігації по впорядкованому наборі записів. Згідно з GraphQL Specification (2025), курсорна пагінація є рекомендованим стандартом для API, що працюють із динамічними даними. Курсорна пагінація усуває головні недоліки Offset-підходу: нестабільність під час вставок і падіння продуктивності на великих зміщеннях.
Головне
Cursor Pagination (курсорна пагінація) — метод посторінкового завантаження, при якому сервер повертає разом із даними спеціальний покажчик — курсор. Клієнт використовує цей курсор у наступному запиті для отримання наступної порції записів. Курсор — це унікальний ідентифікатор останнього елемента поточної сторінки.
На відміну від Offset-пагінації, де клієнт каже «дай мені сторінку 5 по 20 записів», курсорна пагінація працює інакше: «дай мені 20 записів після запису з ID = 83». Сервер виконує запит з умовою WHERE id > 83 і LIMIT 20. Такий підхід гарантує, що кожен запис потрапить рівно на одну сторінку незалежно від вставок.
Концепція курсорної пагінації отримала широке поширення завдяки специфікації Relay Connection (GraphQL), яка зробила cursor-based пагінацію стандартом для сучасних API. Relay визначає формат відповіді: edges (масив записів із курсорами), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-пагінація не є новою технікою — вона використовувалася в базах даних задовго до появи вебу. У SQL це називається keyset pagination або seek method. Метод став популярним в API після публікації специфікації Relay у 2015 році, яка формалізувала формат курсора як base64-encoded рядок для єдності передачі через HTTP.
Базовий принцип курсорної пагінації — запит використовує умову WHERE на індексному полі для позиціонування, а не зміщення. Для прямого напрямку застосовується WHERE id > last_id, для зворотного — WHERE id < first_id. Індекс B-tree дозволяє знайти перший запис після курсора за O(log n), що дає стабільний час відповіді.
-- Получить 20 записей после курсора '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Получить 20 записей ДО курсора '83' (назад)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Курсор може бути простим (значення ID) або складним (складеним із кількох полів). Прості курсори — це первинний ключ запису, наприклад, автоінкрементний id або UUID. Складені курсори використовуються для сортування за не-unique полями, наприклад (created_at, id), де id гарантує унікальність при однакових часових мітках.
Типовий API-формат — курсор у вигляді base64-encoded рядка. Сервер декодує курсор, витягує значення і будує SQL-запит. Base64-кодування приховує внутрішню структуру курсора від клієнта і дозволяє змінити формат без зворотної несумісності. Клієнт отримує курсори в полі endCursor відповіді та передає їх як рядок у наступному запиті.
Курсорна пагінація підтримує двоспрямовану навігацію. Для руху вперед (next) використовується курсор останнього елемента поточної сторінки, для назад (previous) — курсор першого елемента. Параметри after і before у запиті визначають напрямок: after бере записи після курсора, before — до курсора.
Вибір між курсорною та Offset-пагінацією — одне з ключових архітектурних питань при проектуванні API. Кожен метод має сильні та слабкі сторони, які визначають його застосовність. Cursor-пагінація перемагає у сценаріях із динамічними даними, Offset — у сценаріях із довільною навігацією.
| Характеристика | Cursor | Offset |
|---|---|---|
| Стабільність при вставках | Висока (без дублікатів) | Низька (зміщення сторінок) |
| Продуктивність на великих наборах | O(log n) — стабільна | O(n) — падає з ростом |
| Навігація за номером сторінки | Немає | Так (page=5) |
| Складність реалізації | Середня | Низька |
| Підтримка в REST | cursor/before/after | page/offset |
| Підтримка в GraphQL | Стандарт Relay | Не рекомендується |
Offset-пагінація виконує повне сканування таблиці до позиції OFFSET. При offset=100000 база даних читає і пропускає 100000 рядків, навіть якщо LIMIT дорівнює 20. MySQL і PostgreSQL не вміють оптимізувати OFFSET — це особливість реалізації LIMIT/OFFSET в SQL. Cursor-пагінація використовує індекс B-tree, який знаходить позицію за O(log n).
Додаткова проблема Offset — «пропуск» записів при пагінації назад. Якщо користувач завантажив сторінку 5, а в цей момент додалися нові записи, при запиті сторінки 6 він побачить запис зі сторінки 5 знову або пропустить нові. Cursor-пагінація повністю виключає цей сценарій: курсор вказує на конкретне місце в наборі, і вставки не змінюють позицію.
Розглянемо реалізацію курсорної пагінації на бекенді (Kotlin + Spring) і на клієнті (Android + Retrofit). Сервер приймає параметри after, before, limit і повертає список записів із курсорами та pageInfo. Типова відповідь містить hasNextPage і hasPreviousPage для управління UI пагінації.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
На клієнті курсорна пагінація реалізується через PagingSource із Paging 3, де ключем виступає курсор (Long). PagingSource.load отримує LoadParams.key — курсор останнього завантаженого запису. LoadResult.Page повертає дані та nextKey — курсор для наступної сторінки. Коли nextKey = null — пагінацію завершено.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource з ключем курсора
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
У GraphQL курсорна пагінація реалізується через Connection-патерн Relay. Кожен тип має Connection (із pageInfo та edges) і Edge (node + cursor). Запит передає параметри first, after, last, before. Сервер повертає масив edges із курсорами та pageInfo із hasNextPage/hasPreviousPage.
Cursor-пагінація рекомендується для API, що працюють із динамічними даними, де записи часто додаються або видаляються. Класичні приклади: стрічка новин у соціальній мережі, повідомлення в чаті, історія транзакцій, коментарі до посту. У всіх цих сценаріях важлива консистентність і відсутність дублікатів.
Існують сценарії, де Offset-пагінація зручніша: адміністративні панелі, де потрібна навігація за номером сторінки; пошук із пагінацією, де результати можуть змінюватися; звіти та аналітика, де потрібне фіксоване посилання на сторінку 5. У цих випадках переваги курсора не переважують складність реалізації.
Курсорна пагінація не підтримує «стрибок» на довільну сторінку — користувач не може натиснути «Сторінка 5» і потрапити на неї. Це обмеження архітектурне: для підрахунку загальної кількості сторінок потрібен окремий count-запит, який може бути дорогим для великих таблиць. У таких випадках гібридний підхід: cursor для даних + count для пагінації.
Часті запитання
Курсор — це унікальний ідентифікатор запису, який вказує на позицію в наборі даних. Він може бути простим (ID запису) або складеним (кілька полів). Клієнт отримує курсор останнього запису сторінки та передає його в наступному запиті для отримання наступної порції.
Cursor-пагінація не піддається зміщенню при додаванні нових записів — кожен елемент потрапляє рівно на одну сторінку. Вона також зберігає швидкість на великих обсягах завдяки використанню індексів замість сканування перших n рядків. Offset простіший, але нестабільний для динамічних даних.
Так, курсорна пагінація не прив—язана до GraphQL. Її можна реалізувати в будь-якому REST API, передаючи курсор як query-параметр ?after=83&limit=20. Відповідь має містити pageInfo з endCursor і hasNextPage — це дозволяє клієнту керувати завантаженням без знання внутрішньої структури курсора.
Автоінкрементний ID — оптимальний вибір: монотонно зростає, не змінюється, ефективно індексується. UUID v7 (time-ordered) теж підходить. Timestamp може давати дублікати при однаковому часі, тому комбінуйте його з ID: (created_at, id) для гарантії унікальності курсора.
Курсорна пагінація не надає загальної кількості сторінок — це її обмеження. Якщо потрібна інформація про total, виконайте окремий COUNT-запит із тими ж фільтрами. Для великих таблиць використовуйте приблизний підрахунок через EXPLAIN або кешований total з аналітики.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також