Cursor Pagination у мобільній розробці — що це, принцип і реалізація

Автор: IT Sectr Опубліковано: 2026-03-11 Час читання: 9 хв

Cursor Pagination — це метод посторінкового завантаження даних, який використовує унікальний курсор для навігації по впорядкованому наборі записів. Згідно з GraphQL Specification (2025), курсорна пагінація є рекомендованим стандартом для API, що працюють із динамічними даними. Курсорна пагінація усуває головні недоліки Offset-підходу: нестабільність під час вставок і падіння продуктивності на великих зміщеннях.

Головне

  • Cursor Pagination — метод пагінації, де кожен запис має унікальний ідентифікатор-курсор для навігації.
  • Курсор — унікальний маркер позиції в наборі даних (зазвичай ID, UUID, timestamp), який не змінюється під час вставок.
  • Стабільність — нові записи, додані між запитами, не зсувають курсор, виключаючи дублікати та пропуски.
  • Продуктивність — запит WHERE id > cursor ефективно використовує індекс, не втрачаючи швидкість на великих наборах.
  • Обмеження — курсорна пагінація не підтримує навігацію за номером сторінки (не можна стрибнути на сторінку 5).

Що таке Cursor пагінація?

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), що дає стабільний час відповіді.

sql
-- Получить 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 — до курсора.

Cursor vs Offset пагінація

Вибір між курсорною та Offset-пагінацією — одне з ключових архітектурних питань при проектуванні API. Кожен метод має сильні та слабкі сторони, які визначають його застосовність. Cursor-пагінація перемагає у сценаріях із динамічними даними, Offset — у сценаріях із довільною навігацією.

ХарактеристикаCursorOffset
Стабільність при вставкахВисока (без дублікатів)Низька (зміщення сторінок)
Продуктивність на великих наборахO(log n) — стабільнаO(n) — падає з ростом
Навігація за номером сторінкиНемаєТак (page=5)
Складність реалізаціїСередняНизька
Підтримка в RESTcursor/before/afterpage/offset
Підтримка в GraphQLСтандарт RelayНе рекомендується

Чому Offset програє на scale

Offset-пагінація виконує повне сканування таблиці до позиції OFFSET. При offset=100000 база даних читає і пропускає 100000 рядків, навіть якщо LIMIT дорівнює 20. MySQL і PostgreSQL не вміють оптимізувати OFFSET — це особливість реалізації LIMIT/OFFSET в SQL. Cursor-пагінація використовує індекс B-tree, який знаходить позицію за O(log n).

Додаткова проблема Offset — «пропуск» записів при пагінації назад. Якщо користувач завантажив сторінку 5, а в цей момент додалися нові записи, при запиті сторінки 6 він побачить запис зі сторінки 5 знову або пропустить нові. Cursor-пагінація повністю виключає цей сценарій: курсор вказує на конкретне місце в наборі, і вставки не змінюють позицію.

Реалізація Cursor пагінації

Розглянемо реалізацію курсорної пагінації на бекенді (Kotlin + Spring) і на клієнті (Android + Retrofit). Сервер приймає параметри after, before, limit і повертає список записів із курсорами та pageInfo. Типова відповідь містить hasNextPage і hasPreviousPage для управління UI пагінації.

kotlin
@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
        )
    )
}

Клієнтська реалізація на Android

На клієнті курсорна пагінація реалізується через PagingSource із Paging 3, де ключем виступає курсор (Long). PagingSource.load отримує LoadParams.key — курсор останнього завантаженого запису. LoadResult.Page повертає дані та nextKey — курсор для наступної сторінки. Коли nextKey = null — пагінацію завершено.

kotlin
// 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 реалізація через Relay

У GraphQL курсорна пагінація реалізується через Connection-патерн Relay. Кожен тип має Connection (із pageInfo та edges) і Edge (node + cursor). Запит передає параметри first, after, last, before. Сервер повертає масив edges із курсорами та pageInfo із hasNextPage/hasPreviousPage.

Коли використовувати Cursor пагінацію

Cursor-пагінація рекомендується для API, що працюють із динамічними даними, де записи часто додаються або видаляються. Класичні приклади: стрічка новин у соціальній мережі, повідомлення в чаті, історія транзакцій, коментарі до посту. У всіх цих сценаріях важлива консистентність і відсутність дублікатів.

  • Чати та месенджери — кожне нове повідомлення додається в топ списку. Offset-пагінація збивається при кожному новому повідомленні.
  • Соціальні мережі та стрічки — пости публікуються безперервно. Курсорна пагінація гарантує, що користувач не пропустить жодного поста.
  • Історія замовлень і транзакцій — дані змінюються рідше, але консистентність критична для фінансової звітності.
  • API з великими обсягами даних — мільйони записів. Cursor-пагінація зберігає продуктивність, де Offset починає гальмувати.
  • GraphQL API — Relay-стандарт зобов’язує використовувати cursor-based пагінацію для відповідності специфікації.
  • Мобільні застосунки з нескінченним скролом — користувач скролить униз, підвантажуючи нові порції. Курсорний підхід дає плавний UX без дублікатів.

Коли Cursor-пагінація не підходить

Існують сценарії, де Offset-пагінація зручніша: адміністративні панелі, де потрібна навігація за номером сторінки; пошук із пагінацією, де результати можуть змінюватися; звіти та аналітика, де потрібне фіксоване посилання на сторінку 5. У цих випадках переваги курсора не переважують складність реалізації.

Курсорна пагінація не підтримує «стрибок» на довільну сторінку — користувач не може натиснути «Сторінка 5» і потрапити на неї. Це обмеження архітектурне: для підрахунку загальної кількості сторінок потрібен окремий count-запит, який може бути дорогим для великих таблиць. У таких випадках гібридний підхід: cursor для даних + count для пагінації.

Часті запитання

Що таке курсор у Cursor пагінації?

Курсор — це унікальний ідентифікатор запису, який вказує на позицію в наборі даних. Він може бути простим (ID запису) або складеним (кілька полів). Клієнт отримує курсор останнього запису сторінки та передає його в наступному запиті для отримання наступної порції.

Чим Cursor пагінація краща за Offset?

Cursor-пагінація не піддається зміщенню при додаванні нових записів — кожен елемент потрапляє рівно на одну сторінку. Вона також зберігає швидкість на великих обсягах завдяки використанню індексів замість сканування перших n рядків. Offset простіший, але нестабільний для динамічних даних.

Чи можна реалізувати курсорну пагінацію без GraphQL?

Так, курсорна пагінація не прив—язана до GraphQL. Її можна реалізувати в будь-якому REST API, передаючи курсор як query-параметр ?after=83&limit=20. Відповідь має містити pageInfo з endCursor і hasNextPage — це дозволяє клієнту керувати завантаженням без знання внутрішньої структури курсора.

Який курсор використовувати — ID, UUID чи timestamp?

Автоінкрементний ID — оптимальний вибір: монотонно зростає, не змінюється, ефективно індексується. UUID v7 (time-ordered) теж підходить. Timestamp може давати дублікати при однаковому часі, тому комбінуйте його з ID: (created_at, id) для гарантії унікальності курсора.

Як дізнатися загальну кількість сторінок при курсорній пагінації?

Курсорна пагінація не надає загальної кількості сторінок — це її обмеження. Якщо потрібна інформація про total, виконайте окремий COUNT-запит із тими ж фільтрами. Для великих таблиць використовуйте приблизний підрахунок через EXPLAIN або кешований total з аналітики.

Підсумки

  • Cursor Pagination — метод пагінації з навігацією за унікальним ідентифікатором запису замість зміщення.
  • Курсор гарантує стабільність набору при вставках: нові записи не зсувають уже завантажені сторінки.
  • Продуктивність на великих обсягах даних залишається високою (O(log n)) завдяки використанню B-tree індексів.
  • Cursor-пагінація підходить для динамічних даних: чати, стрічки новин, транзакції, коментарі.
  • Головне обмеження — відсутність навігації за номером сторінки та неможливість стрибнути на довільну сторінку.
  • Реалізація використовує WHERE id після cursor, параметри after/before і pageInfo у відповіді.
  • Стандарт — Relay Connection GraphQL, але REST API з cursor-параметрами також широко поширені.

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також