Cursor Pagination у мобилном развоју — шта је то, принцип и имплементација

Аутор: IT Sectr Објављено: 2026-03-11 Време читања: 9 мин

Cursor Pagination — метод страничења података који користи јединствени курсор за навигацију кроз уређени скуп записа. Према GraphQL Specification (2025), курсорно страничење је препоручени стандард за API-је који раде са динамичким подацима. Cursor Pagination отклања главне недостатке Offset приступа: нестабилност при уметању и пад перформанси при великим померањима.

Главне тачке

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

Шта је Cursor Pagination?

Cursor Pagination (курсорно страничење) — метод страничења где сервер заједно са подацима враћа посебан показивач — курсор. Клијент користи овај курсор у следећем захтеву за добијање следеће порције записа. Курсор је јединствени идентификатор последњег елемента текуће странице.

За разлику од Offset-страничења, где клијент каже „дај ми страницу 5 по 20 записа”, курсорно страничење ради другачије: „дај ми 20 записа после записа са ID = 83”. Сервер извршава упит са условом WHERE id > 83 и LIMIT 20. Такав приступ гарантује да сваки запис доспева тачно на једну страницу без обзира на уметања.

Концепт курсорног страничења је стекао широку распрострањеност захваљујући спецификацији Relay Connection (GraphQL), која је учинила cursor-based pagination стандардом за савремене API-је. Relay дефинише формат одговора: edges (низ записа са курсорима), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).

Историја настанка

Cursor-pagination није нова техника — коришћена је у базама података много пре појаве веба. У SQL-у се то назива keyset pagination или seek method. Метод је постао популаран у API-јима након објављивања Relay спецификације 2015. године, која је формализовала формат курсора као base64-кодовани стринг ради уједначености преноса путем 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. Сложени курсори се користе за сортирање по не-јединственим пољима, на пример (created_at, id), где id гарантује јединственост при истим временским ознакама.

Типичан API формат — курсор у облику base64-кодованог стринга. Сервер декодује курсор, издваја вредност и гради SQL упит. Base64 кодирање скрива унутрашњу структуру курсора од клијента и омогућава промену формата без губитка компатибилности уназад. Клијент добија курсоре у пољу endCursor одговора и прослеђује их као стринг у следећем захтеву.

Навигација напред и назад

Курсорно страничење подржава двосмерну навигацију. За кретање напред (next) користи се курсор последњег елемента текуће странице, за назад (previous) — курсор првог елемента. Параметри after и before у захтеву одређују смер: after узима записе после курсора, before — пре курсора.

Cursor vs Offset pagination

Избор између курсорног и Offset-страничења — једно од кључних архитектонских питања при дизајнирању API-ја. Свака метода има јаке и слабе стране. Cursor-pagination побеђује у сценаријима са динамичким подацима, Offset — у сценаријима са произвољном навигацијом.

КарактеристикаCursorOffset
Стабилност при уметањуВисока (без дупликата)Ниска (померање страница)
Перформансе на великим скуповимаO(log n) — стабилнаO(n) — опада са растом
Навигација по броју страницеНеДа (page=5)
Сложеност имплементацијеСредњаНиска
Подршка у REST-уcursor/before/afterpage/offset
Подршка у GraphQL-уRelay стандардНе препоручује се

Зашто Offset губи на великој скали

Offset-страничење врши потпуно скенирање табеле до позиције OFFSET. При offset=100000, база података чита и прескаче 100000 редова, чак и ако је LIMIT 20. MySQL и PostgreSQL не умеју да оптимизују OFFSET — то је карактеристика имплементације LIMIT/OFFSET у SQL-у. Cursor-pagination користи B-tree индекс, који проналази позицију у O(log n).

Додатни проблем Offset-а — „прескакање” записа при страничењу уназад. Ако је корисник учитао страницу 5, а у том тренутку су додати нови записи, при захтеву за страницу 6 videće запис са странице 5 поново или ће прескочити нове. Cursor-pagination потпуно елиминише овај сценарио: курсор показује на конкретно место у скупу, а уметања не мењају позицију.

Имплементација Cursor Pagination-а

Размотримо имплементацију курсорног страничења на бекенду (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 Pagination

Cursor-pagination се препоручује за API-је који раде са динамичким подацима, где се записи често додају или бришу. Класични примери: ток вести у друштвеној мрежи, поруке у чету, историја трансакција, коментари на пост. У свим овим сценаријима важна је конзистентност и одсуство дупликата.

  • Четови и месенџери — свака нова порука се додаје на врх листе. Offset-страничење се помера при свакој новој поруци.
  • Друштвене мреже и токови — постови се објављују непрекидно. Курсорно страничење гарантује да корисник не пропушта ниједан пост.
  • Историја поруџбина и трансакција — подаци се ређе мењају, али је конзистентност критична за финансијско извештавање.
  • API-ји са великим обимом података — милиони записа. Cursor-pagination одржава перформансе тамо где Offset почиње да успорава.
  • GraphQL API — Relay стандард захтева коришћење cursor-based pagination у складу са спецификацијом.
  • Мобилне апликације са бесконачним скроловањем — корисник скролује надоле, учитавајући нове порције. Курсорни приступ пружа глатко UX без дупликата.

Када Cursor-pagination није одговарајућа

Постоје сценарији где је Offset-страничење практичније: административни панели, где је потребна навигација по броју странице; претрага са страничењем, где резултати могу да се мењају; извештаји и аналитика, где је потребан фиксни линк до странице 5. У овим случајевима предности курсора не надмашују сложеност имплементације.

Курсорно страничење не подржава „скок” на произвољну страницу — корисник не може да кликне „Страница 5” и да стигне до ње. Ово је архитектонско ограничење: за израчунавање укупног броја страница потребан је посебан COUNT упит, који може бити скуп за велике табеле. У таквим случајевима хибридни приступ: cursor за податке + count за страничење.

Често постављана питања

Шта је курсор у Cursor Pagination-у?

Курсор је јединствени идентификатор записа који указује на позицију у скупу података. Може бити једноставан (ID записа) или сложен (више поља). Клијент добија курсор последњег записа странице и прослеђује га у следећем захтеву за добијање следеће порције.

По чему је Cursor Pagination бољи од Offset-а?

Cursor-pagination није подложан померању при додавању нових записа — сваки елемент доспева тачно на једну страницу. Такође одржава брзину на великим обимима захваљујући коришћењу индекса уместо скенирања првих n редова. Offset је једноставнији, али нестабилан за динамичке податке.

Може ли се курсорно страничење имплементирати без GraphQL-а?

Да, курсорно страничење није везано за GraphQL. Може се имплементирати у било ком REST API-ју, прослеђивањем курсора као query параметра ?after=83&limit=20. Одговор треба да садржи pageInfo са endCursor и hasNextPage — то омогућава клијенту да управља учитавањем без познавања унутрашње структуре курсора.

Који курсор користити — ID, UUID или timestamp?

Аутоинкремент ID — оптималан избор: монотоно расте, не мења се, ефикасно се индексира. UUID v7 (временски уређен) је такође погодан. Timestamp може давати дупликате при истом времену, зато се комбинује са ID: (created_at, id) ради гаранције јединствености курсора.

Како сазнати укупан број страница код курсорног страничења?

Курсорно страничење не пружа укупан број страница — то је његово ограничење. Ако је потребна информација о total, извршите посебан COUNT упит са истим филтерима. За велике табеле користите приближно бројање путем EXPLAIN-а или кеширани total из аналитике.

Закључак

  • Cursor Pagination — метод страничења са навигацијом по јединственом идентификатору записа уместо померања.
  • Курсор гарантује стабилност скупа при уметању: нови записи не померају већ учитане странице.
  • Перформансе на великим обимима података остају високе (O(log n)) захваљујући коришћењу B-tree индекса.
  • Cursor-pagination је погодан за динамичке податке: четови, токови вести, трансакције, коментари.
  • Главно ограничење — недостатак навигације по броју странице и немогућност скока на произвољну страницу.
  • Имплементација користи WHERE id после курсора, параметре after/before и pageInfo у одговору.
  • Стандард — Relay Connection GraphQL, али REST API са курсор параметрима је такође широко распрострањен.

Развићемо мобилну апликацију под кључ

IT Sectr креира iOS и Android апликације за стартапе и предузећа од 2017. године. Саветоваћемо вас и предложити најбоље решење.

Разговарајте о пројекту

Прочитајте такође