Cursor Pagination — metod för datapaginering som använder en unik markör för navigering genom en ordnad uppsättning poster. Enligt GraphQL Specification (2025) är markörpaginering den rekommenderade standarden för API:er som arbetar med dynamisk data. Cursor Pagination eliminerar de största nackdelarna med Offset-metoden: instabilitet vid insättningar och prestandaförlust vid stora förskjutningar.
Huvudpunkter
Cursor Pagination (markörpaginering) — pagineringsmetod där servern tillsammans med data returnerar en speciell pekare — markör. Klienten använder denna markör i nästa förfrågan för att hämta nästa omgång poster. Markören är den unika identifieraren för det sista elementet på den aktuella sidan.
Till skillnad från Offset-paginering, där klienten säger “ge mig sidan 5 med 20 poster”, fungerar markörpaginering annorlunda: “ge mig 20 poster efter posten med ID = 83”. Servern utför frågan med villkoret WHERE id > 83 och LIMIT 20. Detta tillvägagångssätt garanterar att varje post hamnar exakt på en sida oavsett insättningar.
Konceptet med markörpaginering har blivit utbrett tack vare specifikationen Relay Connection (GraphQL), som gjorde cursor-based pagination till standard för moderna API:er. Relay definierar svarsformatet: edges (array av poster med markörer), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-pagination är inte en ny teknik — den användes i databaser långt innan webben uppstod. I SQL kallas det keyset pagination eller seek method. Metoden blev populär i API:er efter publiceringen av Relay-specifikationen 2015, som formaliserade markörformatet som en base64-kodad sträng för enhetlig överföring via HTTP.
Grundprincipen för markörpaginering — frågan använder WHERE-villkor på ett indexerat fält för positionering, inte förskjutning. För framåtriktning används WHERE id > last_id, för bakåtriktning — WHERE id < first_id. B-tree-indexet gör det möjligt att hitta den första posten efter markören på O(log n) tid, vilket ger stabil svarstid.
-- Hämta 20 poster efter markören '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Hämta 20 poster FÖRE markören '83' (bakåt)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Markören kan vara enkel (ID-värde) eller komplex (sammansatt av flera fält). Enkla markörer är postens primärnyckel, till exempel autoinkrement id eller UUID. Komplexa markörer används för sortering efter icke-unika fält, till exempel (created_at, id), där id garanterar unikhet vid identiska tidsstämplar.
Typiskt API-format — markör som en base64-kodad sträng. Servern avkodar markören, extraherar värdet och bygger SQL-frågan. Base64-kodning döljer markörens interna struktur för klienten och gör det möjligt att ändra format utan att förlora bakåtkompatibilitet. Klienten får markörer i svarets endCursor-fält och skickar dem som en sträng i nästa förfrågan.
Markörpaginering stöder tvåvägsnavigering. För framåtrörelse (next) används markören för det sista elementet på den aktuella sidan, för bakåtrörelse (previous) — markören för det första elementet. Parametrarna after och before i förfrågan bestämmer riktningen: after hämtar poster efter markören, before — före markören.
Valet mellan markör- och Offset-paginering — en av de viktigaste arkitektoniska frågorna vid design av API:er. Varje metod har styrkor och svagheter. Cursor-pagination vinner i scenarier med dynamisk data, Offset — i scenarier med godtycklig navigering.
| Egenskap | Cursor | Offset |
|---|---|---|
| Stabilitet vid insättning | Hög (inga dubbletter) | Låg (siddförskjutning) |
| Prestanda på stora mängder | O(log n) — stabil | O(n) — minskar med tillväxt |
| Navigering efter sidnummer | Nej | Ja (page=5) |
| Implementeringskomplexitet | Medel | Låg |
| Stöd i REST | cursor/before/after | page/offset |
| Stöd i GraphQL | Relay-standard | Rekommenderas inte |
Offset-paginering utför fullständig tabellsökning till OFFSET-positionen. Vid offset=100000 läser databasen och hoppar över 100000 rader, även om LIMIT är 20. MySQL och PostgreSQL kan inte optimera OFFSET — detta är en egenskap i LIMIT/OFFSET-implementeringen i SQL. Cursor-pagination använder B-tree-indexet, som hittar positionen på O(log n) tid.
Ytterligare ett problem med Offset — “överhoppning” av poster vid bakåtpaginering. Om användaren laddade sidan 5 och nya poster lades till i det ögonblicket, kommer hen vid begäran av sidan 6 att se posten från sidan 5 igen eller missa nya. Cursor-pagination eliminerar helt detta scenario: markören pekar på en specifik plats i mängden och insättningar ändrar inte positionen.
Låt oss titta på implementeringen av markörpaginering på backend (Kotlin + Spring) och på klienten (Android + Retrofit). Servern tar emot parametrarna after, before, limit och returnerar en lista med poster med markörer och pageInfo. Ett typiskt svar innehåller hasNextPage och hasPreviousPage för hantering av pagineringsgränssnittet.
@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
)
)
}
På klientsidan implementeras markörpaginering via PagingSource från Paging 3, där nyckeln är markören (Long). PagingSource.load får LoadParams.key — markören för den senast laddade posten. LoadResult.Page returnerar data och nextKey — markör för nästa sida. När nextKey = null — är pagineringen slutförd.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource med markörnyckel
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)
}
}
I GraphQL implementeras markörpaginering via Relay Connection-mönstret. Varje typ har Connection (med pageInfo och edges) och Edge (node + cursor). Förfrågan skickar parametrarna first, after, last, before. Servern returnerar en array av edges med markörer och pageInfo med hasNextPage/hasPreviousPage.
Cursor-pagination rekommenderas för API:er som arbetar med dynamisk data, där poster ofta läggs till eller tas bort. Klassiska exempel: nyhetsflöde i sociala nätverk, chattmeddelanden, transaktionshistorik, kommentarer på inlägg. I alla dessa scenarier är konsistens och frånvaro av dubbletter viktigt.
Det finns scenarier där Offset-paginering är bekvämare: administrativa paneler där navigering efter sidnummer behövs; sökning med paginering där resultat kan ändras; rapporter och analys där en fast länk till sidan 5 behövs. I dessa fall väger markörens fördelar inte upp implementeringskomplexiteten.
Markörpaginering stöder inte “hopp” till en godtycklig sida — användaren kan inte klicka på “Sida 5” och komma dit. Detta är en arkitektonisk begränsning: för att beräkna det totala antalet sidor krävs en separat COUNT-fråga, som kan vara dyr för stora tabeller. I sådana fall en hybridmetod: cursor för data + count för paginering.
Vanliga frågor
En markör är unik identifierare för en post som anger positionen i datamängden. Den kan vara enkel (postens ID) eller komplex (flera fält). Klienten får markören för sidans sista post och skickar den i nästa förfrågan för att få nästa omgång.
Cursor-pagination påverkas inte av förskjutning när nya poster läggs till — varje element hamnar exakt på en sida. Den behåller också hastigheten på stora volymer tack vare användning av index istället för att skanna de första n raderna. Offset är enklare men instabil för dynamisk data.
Ja, markörpaginering är inte bunden till GraphQL. Den kan implementeras i vilket REST API som helst genom att skicka markören som en frågeparameter ?after=83&limit=20. Svaret bör innehålla pageInfo med endCursor och hasNextPage — detta gör det möjligt för klienten att hantera laddningen utan kännedom om markörens interna struktur.
Autoinkrement ID — optimalt val: ökar monotont, ändras inte, indexeras effektivt. UUID v7 (tidsordnad) är också lämplig. Timestamp kan ge dubbletter vid samma tid, därför kombineras den med ID: (created_at, id) för att garantera markörens unikhet.
Markörpaginering ger inte det totala antalet sidor — detta är dess begränsning. Om information om total behövs, utför en separat COUNT-fråga med samma filter. För stora tabeller, använd ungefärlig räkning via EXPLAIN eller cachad total från analys.
Sammanfattning
Vi utvecklar en mobil applikation nyckelfärdigt
IT Sectr skapar iOS- och Android-applikationer för startups och företag sedan 2017. Vi ger dig råd och föreslår den bästa lösningen.
Läs också