5 min

Paginacja kursorem: stabilne listy API bez zagadkowych błędów

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.

Paginacja kursorem: stabilne listy API bez zagadkowych błędów

Problem: strony listy zmieniają się pod palcami

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:

  • Duplikaty elementów pomiędzy stronami
  • Brakujące elementy, które nigdy nie zostały pokazane
  • Elementy przesuwające się na inną pozycję podczas przewijania
  • Infinite scroll, który zatrzymuje się za wcześnie lub ładuje tę samą stronę ponownie

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.

Offsetowa paginacja w minutę

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”.

Dlaczego offset zawodzi przy wstawieniach lub usunięciach wierszy

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.

Co oznacza „stabilna lista” dla web i mobile

„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:

  • Stabilne sortowanie: istnieje jasna reguła sortowania (np. created_at z tie-breakerem jak id), więc dwa zapytania z tymi samymi wejściami zwrócą ten sam porządek.
  • Stabilne stronicowanie: gdy użytkownik zaczyna przewijać do przodu, następna strona kontynuuje od ostatniego elementu, który widział, nawet jeśli pojawiają się nowe elementy lub niektóre są usuwane.

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: podstawowy pomysł

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:

  • API zwraca partię elementów plus kursor reprezentujący pozycję po ostatnim elemencie.
  • Klient odsyła ten kursor, aby pobrać kolejną partię.

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ą.

Wybór kursora i porządku sortowania

Zaprojektuj odświeżanie we właściwy sposób
Zaprojektuj jasny przepływ odświeżania, który pokazuje nowe elementy bez przetasowywania już przewiniętych stron.

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.

Krok po kroku: przejrzysty kształt API kursora

Możesz utrzymać powierzchnię API małą: dwa wejścia, dwa wyjścia.

1) Żądanie: limit + cursor

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==

2) Odpowiedź: items + next_cursor

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.

3) Kodowanie kursora: niejawny token wygrywa

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.

Obsługa wstawek, usunięć i edycji elementów

Eksportuj kod źródłowy
Zachowaj kontrolę, eksportując kod źródłowy React, Go i Flutter, gdy będziesz gotowy.

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?

Wybierz zachowanie: migawka czy live

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.

Gdy element kursora już nie istnieje

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:

  • Dla porządku malejącego: WHERE (created_at, id) < (:created_at, :id)
  • Zawsze dołącz tie-breaker (id), aby uniknąć duplikatów
  • Jeśli ostatni element został usunięty, wartości nadal działają
  • Jeśli ostatni element został edytowany, nadal to działa, o ile klucze sortujące są niemutowalne

Stronicowanie wstecz, odświeżanie i skoki

Stronicowanie 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ą:

  • Zwracaj oba kierunki (next_cursor dla starszych i prev_cursor dla nowszych) przy zachowaniu jednolitego porządku na ekranie.
  • Trzymaj pojedynczy kursor, ale żądaj odwróconego sortu, gdy użytkownik przewija w górę.

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”.

Częste błędy tworzące zagadkowe bugi

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:

  • Używanie nieuniknięgo kursora (np. samego created_at), więc związy powodują powtórki lub brakujące elementy.
  • Zwracanie next_cursor, który nie odpowiada faktycznemu ostatniemu zwróconemu elementowi.
  • Zmienianie filtrów lub porządku sortowania między żądaniami stron.
  • Mieszanie offsetu i kursora na tym samym endpointzie.

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.

Szybka lista kontrolna przed wydaniem

Jedno API dla wszystkich klientów
Zbuduj wspólną logikę paginacji, aby mobilne i webowe listy zachowywały się identycznie.

Zanim uznasz paginację za „gotową”, zweryfikuj zachowanie przy wstawieniach, usunięciach i powtórzeniach.

  • Sort jest jawny, deterministyczny i zawiera tie-breaker
  • Każde żądanie powtarza identyczne filtry i pola sortowania
  • next_cursor pochodzi z ostatniego zwróconego wiersza
  • limit ma bezpieczny max i udokumentowane domyślne ustawienie
  • Zachowanie odświeżania jest zdefiniowane (w jaki sposób pojawiają się nowe elementy)

Dla 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.

Realistyczny przykład: skrzynka odbiorcza stabilna między urządzeniami

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:

  • Zacznij pobierać strony, podczas gdy skrypt wstawia nowe wiadomości co sekundę
  • Usuń kilka wiadomości w połowie przewijania
  • Edytuj wiadomość (bez zmiany pól porządkujących)
  • Potwierdź, że nigdy nie dostajesz powtórek, luk ani elementów w złym porządku
  • Zweryfikuj, że aplikacja mobilna i web pokazują spójne granice stron

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.

Często zadawane pytania

Dlaczego otrzymuję duplikaty lub brakujące elementy przy paginacji offsetowej?

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.

Jak paginacja kursorem zapobiega problemowi „lista zmieniła się przede mną”?

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.

Czego użyć jako kursora: created_at, id czy obu?

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.

Czy mogę paginować po updated_at dla widoku live?

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.

Co powinien zawierać mój response API dla paginacji kursorem?

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.

Co się stanie, jeśli element wskazany przez kursor zostanie usunięty?

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.

Jak uniknąć powtórek przy pobieraniu następnej strony?

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.

Jak powinno działać odświeżanie z paginacją kursorem?

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ą.

Czy mogę ponownie użyć tego samego kursora po zmianie filtrów lub trybu sortowania?

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.

Jak wspierać przejście do konkretnej strony lub dostęp losowy przy użyciu kursora?

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.

Related posts