Paginacja kursorem utrzymuje listy stabilne przy zmianach danych. Dowiedz się, dlaczego stronicowanie offsetowe zawodzi przy wstawieniach i usunięciach oraz jak wdrożyć poprawne kursory.

Otwierasz feed, przewijasz trochę i wszystko wydaje się w porządku — aż nagle nie. Widzisz ten sam element dwa razy. Coś, o czym jesteś przekonany, że tam było, znika. Wiersz, który miałeś zamiar stuknąć, przesuwa się w dół i trafiasz na niewłaściwą stronę szczegółów.
To są błędy widoczne dla użytkownika, nawet jeśli odpowiedzi API wyglądają „poprawnie” w izolacji. Zwykłe objawy są łatwe do zauważenia:
Na mobile to się pogarsza. Ludzie pauzują, przełączają aplikacje, tracą łączność, a potem wracają. W tym czasie pojawiają się nowe elementy, stare są usuwane, a niektóre edytowane. Jeśli aplikacja wciąż pyta o „stronę 3” używając offsetu, granice stron mogą przesunąć się, gdy użytkownik jest w połowie przewijania. Efekt to feed, który wydaje się niestabilny i niegodny zaufania.
Cel jest prosty: gdy użytkownik zaczyna przewijać do przodu, lista powinna zachowywać się jak migawka. Nowe elementy mogą istnieć, ale nie powinny przetasowywać tego, przez co użytkownik już przewija. Użytkownik powinien otrzymać płynną, przewidywalną sekwencję.
Żadna metoda paginacji nie jest idealna. W prawdziwych systemach zachodzą współbieżne zapisy, edycje i istnieje wiele opcji sortowania. Jednak paginacja kursorem jest zwykle bezpieczniejsza niż offsetowa, ponieważ stronicuje z pozycji w stabilnym porządku, zamiast od licznika wierszy, który się przesuwa.
Paginacja offsetowa to sposób „pomiń N, weź M” na przechodzenie przez listę. Mówisz API, ile elementów pominąć (offset) i ile zwrócić (limit). Przy limit=20 dostajesz 20 elementów na stronę.
Konceptualnie:
GET /items?limit=20\u0026offset=0 (pierwsza strona)GET /items?limit=20\u0026offset=20 (druga strona)GET /items?limit=20\u0026offset=40 (trzecia strona)Odpowiedź zwykle zawiera elementy plus wystarczającą informację, aby zażądać następnej strony.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Jest popularna, bo ładnie mapuje się na tabele, listy administracyjne, wyniki wyszukiwania i proste feedy. Łatwo ją też zaimplementować w SQL używając LIMIT i OFFSET.
Haczyk to ukryte założenie: zbiór danych stoi w miejscu, podczas gdy użytkownik przegląda. W prawdziwych aplikacjach nowe wiersze są wstawiane, usuwane, a klucze sortujące się zmieniają. Właśnie wtedy zaczynają się „zagadkowe błędy”.
Offsetowa paginacja zakłada, że lista się nie zmienia między żądaniami. Ale prawdziwe listy się poruszają. Gdy lista się przesuwa, offset taki jak „pomiń 20” nie wskazuje już tych samych elementów.
Wyobraź sobie feed sortowany po created_at desc (najpierw najnowsze), rozmiar strony 3.
Ładujesz stronę 1 z offset=0, limit=3 i dostajesz [A, B, C].
Teraz powstaje nowy element X, który pojawia się na górze. Lista to teraz [X, A, B, C, D, E, F, ...]. Ładujesz stronę 2 z offset=3, limit=3. Serwer pomija [X, A, B] i zwraca [C, D, E].
Właśnie zobaczyłeś C ponownie (duplikat), a później pominiesz jakiś element, bo wszystko przesunęło się w dół.
Usunięcia powodują odwrotną awarię. Zacznij od [A, B, C, D, E, F, ...]. Ładujesz stronę 1 i widzisz [A, B, C]. Przed stroną 2 B zostaje usunięte, więc lista staje się [A, C, D, E, F, ...]. Strona 2 z offset=3 pomija [A, C, D] i zwraca [E, F, G]. D staje się dziurą, której nigdy nie pobierzesz.
W feedach najpierw-najnowsze, wstawienia zdarzają się na górze, co dokładnie przesuwa późniejsze offsety.
„Stabilna lista” to to, czego użytkownicy oczekują: podczas przewijania do przodu elementy nie skaczą, nie powtarzają się ani nie znikają bez wyraźnej przyczyny. Chodzi mniej o „zamrożenie czasu”, a bardziej o przewidywalność paginacji.
Dwie idee często są mylone:
created_at z tie-breakerem jak id), więc dwa zapytania z tymi samymi wejściami zwrócą ten sam porządek.Odświeżanie i przewijanie do przodu to różne działania. Odświeżenie znaczy „pokaż mi, co jest teraz nowe”, więc góra może się zmienić. Przewijanie do przodu znaczy „kontynuuj skąd byłem”, więc nie powinieneś widzieć powtórek ani niespodziewanych luk spowodowanych przesuwającymi się granicami stron.
Prosta zasada, która zapobiega większości błędów paginacji: przewijanie do przodu nigdy nie powinno pokazywać powtórek.
Paginacja kursorem porusza się przez listę, używając zakładki zamiast numeru strony. Zamiast „daj mi stronę 3”, klient mówi „kontynuuj stąd”.
Kontrakt jest prosty:
To lepiej toleruje wstawienia i usunięcia, bo kursor kotwiczy się do pozycji w uporządkowanej liście, a nie do licznika wierszy.
Wymaganie niepodlegające dyskusji to deterministyczny porządek sortowania. Potrzebujesz stabilnej reguły porządkowania i spójnego tie-breakera; inaczej kursor nie będzie niezawodną zakładką.
Zacznij od wyboru jednego porządku sortowania, który pasuje do sposobu, w jaki ludzie czytają listę. Feedy, wiadomości i logi aktywności zwykle najpierw-najnowsze. Historie takie jak faktury i logi audytu często łatwiej są najpierw-najstarsze.
Kursor musi jednoznacznie identyfikować pozycję w tym porządku. Jeśli dwa elementy mogą dzielić tę samą wartość kursora, w końcu dostaniesz duplikaty lub luki.
Popularne wybory i na co zwracać uwagę:
created_at samodzielnie: proste, ale niebezpieczne jeśli wiele wierszy ma ten sam znacznik czasu.id samodzielnie: bezpieczne jeśli ID są monotoniczne, ale może nie odpowiadać pożądanemu porządkowi produktu.created_at + id: zwykle najlepsze połączenie (znacznik czasu dla porządku, id jako tie-breaker).updated_at jako główny sort: ryzykowne dla infinite scroll, bo edycje mogą przesuwać elementy między stronami.Jeśli oferujesz wiele trybów sortowania, traktuj każdy tryb jako inną listę z własnymi zasadami kursora. Kursor ma sens tylko dla jednego konkretnego porządku.
Możesz utrzymać powierzchnię API małą: dwa wejścia, dwa wyjścia.
Wyślij limit (ile elementów chcesz) i opcjonalny cursor (skąd kontynuować). Jeśli kursor jest pominięty, serwer zwraca pierwszą stronę.
Przykładowe żądanie:
GET /api/messages?limit=30\u0026cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Zwróć elementy i next_cursor. Jeśli nie ma następnej strony, zwróć next_cursor: null. Klient powinien traktować kursor jako token, a nie coś do edytowania.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Logika po stronie serwera w prostych słowach: posortuj w stabilnym porządku, przefiltruj używając kursora, a potem zastosuj limit.
Jeśli sortujesz najpierw-najnowsze po (created_at DESC, id DESC), zdekoduj kursor do (created_at, id), potem pobierz wiersze gdzie (created_at, id) jest ściśle mniejsze niż para z kursora, zastosuj ten sam porządek i weź limit wierszy.
Możesz zakodować kursor jako base64 JSON (łatwe) albo jako podpisany/szyfrowany token (więcej pracy). Niejawny token jest bezpieczniejszy, bo pozwala zmieniać detale wewnętrzne później bez łamania klientów.
Ustaw też rozsądne domyślne: domyślna wartość dla mobile często 20–30, dla web często 50, oraz maksymalny limit po stronie serwera, żeby jeden wadliwy klient nie poprosił o 10 000 wierszy.
Stabilny feed to głównie jedna obietnica: gdy użytkownik zaczyna przewijać do przodu, elementy, których jeszcze nie widział, nie powinny skakać, bo ktoś inny dodał, usunął lub edytował rekordy.
Przy paginacji kursorem wstawienia są najłatwiejsze. Nowe rekordy powinny pojawiać się po odświeżeniu, nie w środku już załadowanych stron. Jeśli sortujesz po created_at DESC, id DESC, nowe elementy naturalnie znajdują się przed pierwszą stroną, więc istniejący kursor kontynuuje do starszych elementów.
Usunięcia nie powinny przetasowywać listy. Jeśli element zostanie usunięty, po prostu nie zostanie zwrócony, gdy byś go pobierał. Jeśli potrzebujesz utrzymać stały rozmiar stron, kontynuuj pobieranie aż zbierzesz limit widocznych elementów.
Edycje to miejsce, gdzie zespoły przypadkowo przywracają błędy. Kluczowe pytanie: czy edycja może zmienić pozycję w sortowaniu?
Zwykle najlepsze dla przewijanych list jest zachowanie w stylu migawki: paginuj po niemutowalnym kluczu jak created_at. Edycje mogą zmieniać zawartość, ale element nie powinien przeskakiwać na inną pozycję.
Zachowanie live sortuje po czymś takim jak edited_at. To może powodować skoki (stary element zostaje edytowany i trafia na górę). Jeśli to wybierzesz, traktuj listę jako ciągle zmieniającą się i zaprojektuj UX wokół odświeżenia.
Nie zmuszaj kursora do „znajdź ten konkretny wiersz”. Zakoduj pozycję, np. {created_at, id} ostatniego zwróconego elementu. Następne zapytanie opiera się na wartościach, nie na istnieniu wiersza:
WHERE (created_at, id) < (:created_at, :id)id), aby uniknąć duplikatówStronicowanie do przodu jest proste. Trudniejsze pytania UX to stronicowanie wstecz, odświeżanie i dostęp losowy.
Dla stronicowania wstecz dwie podejścia zwykle działają:
next_cursor dla starszych i prev_cursor dla nowszych) przy zachowaniu jednolitego porządku na ekranie.Skakanie losowe jest trudniejsze przy cursorach, bo „strona 20” nie ma stabilnego znaczenia, gdy lista się zmienia. Jeśli naprawdę potrzebujesz skoków, skacz do kotwicy jak „wokół tego znacznika czasu” lub „zaczynając od tego id”, a nie do indeksu strony.
Na mobile cache ma znaczenie. Przechowuj cursory per stan listy (zapytanie + filtry + sort) i traktuj każdą zakładkę/widok jako osobną listę. To zapobiega zachowaniu „przełącz zakładki i wszystko się miesza”.
Większość problemów z paginacją kursorem nie wynika z bazy danych. Pochodzą z małych niespójności między żądaniami, które ujawniają się dopiero pod realnym ruchem.
Największe grzechy:
created_at), więc związy powodują powtórki lub brakujące elementy.next_cursor, który nie odpowiada faktycznemu ostatniemu zwróconemu elementowi.Jeśli budujesz aplikacje na platformach takich jak Koder.ai, te przypadki brzegowe szybko się ujawnią, bo web i mobile często dzielą ten sam endpoint. Jedna eksplicytna umowa na kursor i jedna deterministyczna reguła porządkowania utrzymują obie strony spójne.
Zanim uznasz paginację za „gotową”, zweryfikuj zachowanie przy wstawieniach, usunięciach i powtórzeniach.
next_cursor pochodzi z ostatniego zwróconego wierszalimit ma bezpieczny max i udokumentowane domyślne ustawienieDla odświeżania wybierz jedną jasną regułę: albo użytkownik „pull to refresh”, żeby pobrać nowsze elementy na górze, albo okresowo sprawdzasz „czy jest coś nowszego niż mój pierwszy element?” i pokazujesz przycisk „Nowe elementy”. Spójność sprawia, że lista wydaje się stabilna, a nie nawiedzona.
Wyobraź sobie skrzynkę wsparcia, z której korzystają agenci na webie, a menedżer sprawdza tę samą skrzynkę na mobile. Lista jest sortowana najpierw-najnowsze. Ludzie oczekują jednego: gdy przewijają do przodu, elementy nie przeskakują, nie powtarzają się ani nie znikają.
Z paginacją offsetową agent ładuje stronę 1 (elementy 1–20), potem przewija do strony 2 (offset=20). W czasie czytania pojawiają się dwie nowe wiadomości na górze. Teraz offset=20 wskazuje inne miejsce niż przed chwilą. Użytkownik widzi duplikaty lub traci wiadomości.
Z paginacją kursorem aplikacja pyta o „następne 20 elementów po tym kursorze”, gdzie kursor opiera się na ostatnim faktycznie zobaczonym elemencie użytkownika (zwykle (created_at, id)). Nowe wiadomości mogą przychodzić cały dzień, ale następna strona wciąż zaczyna się tuż za ostatnią wiadomością, którą użytkownik widział.
Prosty test przed wdrożeniem:
Jeśli prototypujesz szybko, Koder.ai może pomóc zbudować szkielet endpointu i przepływów klienckich z promptu, a potem bezpiecznie iterować używając Planning Mode plus snapshotów i rollbacku, gdy zmiana paginacji zaskoczy Cię w testach.
Paginacja offsetowa wskazuje „pomiń N wierszy”, więc kiedy nowe wiersze są wstawiane lub stare usuwane, liczba wierszy się przesuwa. Ten sam offset może nagle odnosić się do innych elementów niż przed chwilą, co powoduje powtórzenia i luki podczas przewijania.
Paginacja kursorem używa zakładki określającej „pozycję po ostatnim zobaczonym elemencie”. Kolejne żądanie kontynuuje od tej pozycji w deterministycznym porządku, więc wstawienia na górze i usunięcia po środku nie przesuwają granicy strony tak jak offset.
Użyj deterministycznego sortu z tie-breakerem, najczęściej (created_at, id) w tym samym kierunku. created_at daje porządek zgodny z produktem, a id zapewnia unikalność pozycji, żeby nie powtarzać ani nie pomijać elementów przy zderzeniu znaczników czasu.
Sortowanie po updated_at może powodować, że elementy będą przeskakiwać między stronami po edycjach, co łamie oczekiwanie „stabilnego przewijania do przodu”. Jeśli potrzebujesz widoku „najbardziej ostatnio zaktualizowane”, zaprojektuj UI jako odświeżalny i zaakceptuj zmianę kolejności zamiast obiecywać stabilny infinite scroll.
Zwracaj niejawny token jako next_cursor, a klient niech odsyła go bez modyfikacji. Prosty sposób to zakodowanie (created_at, id) ostatniego elementu jako base64 JSON, ale ważniejsze jest traktowanie go jako nieprzejrzystej wartości, abyś mógł zmieniać wewnętrzną implementację bez łamania klientów.
Zbuduj kolejne zapytanie z wartości kursora, a nie z „znajdź ten konkretny wiersz”. Jeśli ostatni element został usunięty, zapisane (created_at, id) nadal definiuje pozycję, więc możesz bezpiecznie kontynuować z filtracją „ściśle mniejsze” (lub „większe”) w tym samym porządku.
Używaj ściśle mniejszego/większego porównania i unikalnego tie-breakera, oraz zawsze generuj next_cursor z ostatniego faktycznie zwróconego elementu. Większość błędów z powtórkami wynika z użycia <= zamiast <, pominięcia tie-breakera lub wygenerowania kursora z niewłaściwego wiersza.
Wybierz jasną zasadę: odświeżanie ładuje nowsze elementy na górze, a przewijanie do przodu kontynuuje do starszych elementów od istniejącego kursora. Nie mieszaj semantyki odświeżania w tym samym przepływie kursora, bo użytkownicy zobaczą przetasowania i uznają listę za niestabilną.
Kursor jest ważny tylko dla dokładnego porządku i zestawu filtrów. Jeśli klient zmieni tryb sortowania, zapytanie wyszukiwania lub filtry, musi rozpocząć nową sesję paginacji bez kursora i przechowywać cursory osobno dla każdego stanu listy.
Paginacja kursorem świetnie sprawdza się do sekwencyjnego przeglądania, ale nie do stabilnego skakania na „stronę 20”, ponieważ zestaw danych może się zmieniać. Jeśli potrzebujesz skoku, przejdź do kotwicy jak „wokół tego znacznika czasu” lub „od tego id”, a potem paginuj kursorem stamtąd.