La paginazione a cursore mantiene le liste stabili quando i dati cambiano. Scopri perché l'offset fallisce con inserimenti e cancellazioni e come implementare cursori puliti.

Apri un feed, scorri un po' e tutto sembra normale... finché non lo è. Vedi lo stesso elemento due volte. Qualcosa che giuri fosse lì è scomparso. Una riga che stavi per toccare si sposta e finisci sulla pagina di dettaglio sbagliata.
Questi sono bug visibili all'utente, anche se le risposte dell'API sembrano “corrette” prese isolatamente. I sintomi tipici sono facili da riconoscere:
Questo peggiora su mobile. Le persone mettono in pausa, cambiano app, perdono connettività e poi riprendono. Nel frattempo arrivano nuovi elementi, alcuni vengono cancellati e altri modificati. Se la tua app continua a chiedere la “pagina 3” usando un offset, i confini di pagina possono spostarsi mentre l'utente è a metà scroll. Il risultato è un feed che sembra instabile e inaffidabile.
L'obiettivo è semplice: una volta che un utente inizia a scorrere in avanti, la lista dovrebbe comportarsi come uno snapshot. Possono esistere nuovi elementi, ma non dovrebbero rimescolare ciò che l'utente sta già sfogliando. L'utente dovrebbe avere una sequenza fluida e prevedibile.
Nessun metodo di paginazione è perfetto. I sistemi reali hanno scritture concorrenti, modifiche e più opzioni di ordinamento. Ma la paginazione a cursore è generalmente più sicura dell'offset perché procede a partire da una posizione in un ordine stabile, invece che da un conteggio di righe che si muove.
La paginazione con offset è il modo “salta N, prendi M” per scorrere una lista. Dici all'API quante righe saltare (offset) e quante restituire (limit). Con limit=20 ottieni 20 elementi per pagina.
Concettualmente:
GET /items?limit=20\u0026offset=0 (prima pagina)GET /items?limit=20\u0026offset=20 (seconda pagina)GET /items?limit=20\u0026offset=40 (terza pagina)La risposta di solito include gli elementi più le informazioni per richiedere la pagina successiva.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
È popolare perché si mappa bene a tabelle, liste admin, risultati di ricerca e feed semplici. È anche facile da implementare con SQL usando LIMIT e OFFSET.
Il problema è l'assunzione nascosta: il dataset rimane fermo mentre l'utente sfoglia. Nelle app reali, nuove righe vengono inserite, righe vengono cancellate e le chiavi di ordinamento cambiano. È qui che iniziano i “bug misteriosi”.
La paginazione con offset assume che la lista rimanga ferma tra le richieste. Ma le liste reali si muovono. Quando la lista si sposta, un offset come “salta 20” non punta più agli stessi elementi.
Immagina un feed ordinato per created_at desc (i più recenti prima), dimensione pagina 3.
Carichi la pagina 1 con offset=0, limit=3 e ottieni [A, B, C].
Ora viene creato un nuovo elemento X che appare in cima. La lista è ora [X, A, B, C, D, E, F, ...]. Carichi la pagina 2 con offset=3, limit=3. Il server salta [X, A, B] e restituisce [C, D, E].
Hai appena visto C di nuovo (un duplicato), e più avanti perderai un elemento perché tutto si è spostato verso il basso.
Le cancellazioni causano il fallimento opposto. Parti da [A, B, C, D, E, F, ...]. Carichi la pagina 1 e vedi [A, B, C]. Prima della pagina 2, B viene cancellato, così la lista diventa [A, C, D, E, F, ...]. La pagina 2 con offset=3 salta [A, C, D] e restituisce [E, F, G]. D diventa un buco che non recupererai mai.
Nei feed newest-first, le inserzioni avvengono in cima, ed è proprio questo che sposta ogni offset successivo.
Una “lista stabile” è ciò che gli utenti si aspettano: man mano che scorrono in avanti, gli elementi non saltano, non si ripetono e non svaniscono senza motivo. È meno questione di congelare il tempo e più di rendere la paginazione prevedibile.
Due idee spesso si confondono:
created_at con un tie-breaker come id) così due richieste con gli stessi input restituiscono lo stesso ordine.Refresh e scroll-forward sono azioni diverse. Refresh significa “mostrami cosa c'è di nuovo adesso”, quindi la cima può cambiare. Scroll-forward significa “continua da dove ero rimasto”, quindi non dovresti vedere ripetizioni o gap imprevisti causati dallo spostamento dei confini di pagina.
Una regola semplice che previene la maggior parte dei bug di paginazione: scrolling in avanti non dovrebbe mai mostrare ripetizioni.
La paginazione a cursore scorre una lista usando un segnalibro invece di un numero di pagina. Invece di “dammi la pagina 3”, il client dice “continua da qui”.
Il contratto è semplice:
Questo tollera meglio inserimenti e cancellazioni perché il cursore si ancora a una posizione nell'ordine ordinato, non a un conteggio di righe che si muove.
Il requisito non negoziabile è un ordine deterministico. Ti serve una regola di ordinamento stabile e un tie-breaker coerente, altrimenti il cursore non è un segnalibro affidabile.
Inizia scegliendo un ordine che corrisponda a come le persone leggono la lista. Feed, messaggi e log di attività sono di solito newest first. Cronologie come fatture e audit log sono spesso più comode oldest first.
Un cursore deve identificare in modo univoco una posizione in quell'ordine. Se due elementi possono condividere lo stesso valore di cursore, alla fine otterrai duplicati o gap.
Scelte comuni e cosa controllare:
created_at soltanto: semplice, ma insicuro se molte righe condividono lo stesso timestamp.id soltanto: sicuro se gli ID sono monotoni, ma potrebbe non corrispondere all'ordine prodotto desiderato.created_at + id: di solito il miglior mix (timestamp per l'ordine, id come tie-breaker).updated_at come ordinamento principale: rischioso per lo scroll infinito perché le modifiche possono spostare elementi tra le pagine.Se offri più opzioni di ordinamento, tratta ogni modalità come una lista diversa con le sue regole di cursore. Un cursore ha senso solo per un ordinamento esatto.
Puoi mantenere la superficie dell'API piccola: due input, due output.
Invia un limit (quanti elementi vuoi) e un cursor opzionale (dove continuare). Se il cursore manca, il server restituisce la prima pagina.
Esempio di richiesta:
GET /api/messages?limit=30\u0026cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Restituisci gli elementi e un next_cursor. Se non c'è una pagina successiva, restituisci next_cursor: null. I client dovrebbero trattare il cursore come un token, non come qualcosa da modificare.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Logica server-side in parole semplici: ordina in un ordine stabile, filtra usando il cursore, poi applica il limit.
Se ordini newest first per (created_at DESC, id DESC), decodifica il cursore in (created_at, id), poi prendi le righe dove (created_at, id) è strettamente minore della coppia del cursore, applica lo stesso ordine e prendi limit righe.
Puoi codificare il cursore come un blob JSON in base64 (facile) o come un token firmato/criptato (più lavoro). L'opaco è più sicuro perché ti permette di cambiare l'interno senza rompere i client.
Imposta anche valori di default sensati: un default mobile ragionevole (spesso 20–30), un default web (spesso 50) e un massimo lato server così un client bugato non può richiedere 10.000 righe.
Un feed stabile riguarda principalmente una promessa: una volta che l'utente inizia a scorrere in avanti, gli elementi che non ha ancora visto non dovrebbero rimbalzare perché qualcun altro ha creato, cancellato o modificato record.
Con la paginazione a cursore, le inserzioni sono le più semplici. I nuovi record dovrebbero apparire al refresh, non nel mezzo delle pagine già caricate. Se ordini per created_at DESC, id DESC, i nuovi elementi vivono naturalmente prima della prima pagina, quindi il cursore esistente continua verso elementi più vecchi.
Le cancellazioni non dovrebbero rimescolare la lista. Se un elemento viene cancellato, semplicemente non verrà restituito quando lo avresti dovuto prendere. Se hai bisogno di mantenere la dimensione delle pagine costante, continua a richiedere fino a raccogliere limit elementi visibili.
Le modifiche sono dove i team rientrano accidentalmente nei bug. La domanda chiave è: una modifica può cambiare la posizione nell'ordinamento?
Il comportamento in stile snapshot è di solito il migliore per le liste a scroll: paga con una chiave immutabile come created_at. Le modifiche possono aggiornare il contenuto, ma l'elemento non salta in una nuova posizione.
Il comportamento live ordina per qualcosa come edited_at. Questo può causare salti (un elemento vecchio viene modificato e si sposta in alto). Se scegli questo, tratta la lista come in continuo cambiamento e progetta l'UX attorno al refresh.
Non far dipendere il cursore dal “trova esattamente questa riga”. Codifica la posizione invece, ad esempio {created_at, id} dell'ultimo elemento restituito. Poi la query successiva si basa sui valori, non sull'esistenza della riga:
WHERE (created_at, id) < (:created_at, :id)id) per evitare duplicatiLo paging in avanti è la parte facile. Le domande UX più complicate sono il paging all'indietro, il refresh e l'accesso casuale.
Per il paging all'indietro, due approcci funzionano spesso:
next_cursor per elementi più vecchi e prev_cursor per elementi più recenti) mantenendo un solo ordine sullo schermo.I salti casuali sono più difficili con i cursori perché “pagina 20” non ha un significato stabile quando la lista cambia. Se hai davvero bisogno di saltare, salta ad un anchor come “intorno a questo timestamp” o “a partire da questo id”, non un indice di pagina.
Su mobile, la cache conta. Conserva i cursori per stato della lista (query + filtri + ordinamento) e tratta ogni tab/view come una lista separata. Questo evita comportamenti in cui “cambi tab e tutto si scombina”.
La maggior parte dei problemi di paginazione a cursore non riguarda il database. Nascono da piccole incoerenze tra le richieste che emergono solo sotto traffico reale.
I colpevoli principali:
created_at) così i pareggi producono duplicati o elementi mancanti.next_cursor che non corrisponde all'ultimo elemento effettivamente restituito.Se costruisci app su piattaforme come Koder.ai, questi edge case emergono rapidamente perché client web e mobile spesso condividono lo stesso endpoint. Avere un unico contratto di cursore esplicito e una regola di ordinamento deterministica mantiene entrambi i client coerenti.
Prima di dichiarare la paginazione “fatta”, verifica il comportamento sotto inserimenti, cancellazioni e retry.
next_cursor è preso dall'ultimo elemento effettivamente restituitolimit ha un massimo sicuro e un default documentatoPer il refresh, scegli una regola chiara: o gli utenti fanno pull-to-refresh per recuperare elementi più recenti in cima, oppure controlli periodicamente “c'è qualcosa di più recente del mio primo elemento?” e mostri un pulsante “Nuovi elementi”. La coerenza è ciò che fa sembrare la lista stabile invece che infestata.
Immagina una inbox di supporto che gli agenti usano sul web, mentre un manager la controlla sul mobile. La lista è ordinata per newest first. Le persone si aspettano una cosa: quando scorrono in avanti, gli elementi non devono saltare, ripetersi o scomparire.
Con la paginazione offset, un agente carica la pagina 1 (elementi 1–20), poi scorre alla pagina 2 (offset=20). Mentre legge, arrivano due nuovi messaggi in cima. Ora offset=20 punta a un posto diverso di un secondo prima. L'utente vede duplicati o perde messaggi.
Con la paginazione a cursore, l'app chiede “i prossimi 20 elementi dopo questo cursore”, dove il cursore si basa sull'ultimo elemento che l'utente ha effettivamente visto (comunemente (created_at, id)). I nuovi messaggi possono arrivare tutto il giorno, ma la pagina successiva inizia comunque subito dopo l'ultimo messaggio visto dall'utente.
Un modo semplice per testare prima del rilascio:
Se fai un prototipo veloce, Koder.ai può aiutarti a scaffoldare l'endpoint e i flussi client da un prompt di chat, poi iterare in sicurezza usando Planning Mode più snapshot e rollback quando una modifica di paginazione ti sorprende nei test.
La paginazione con offset indica di “saltare N righe”, quindi quando vengono inserite nuove righe o vengono cancellate righe esistenti, il conteggio delle righe si sposta. Lo stesso offset può improvvisamente riferirsi a elementi diversi rispetto a prima, creando duplicati e gap per gli utenti a metà scroll.
La paginazione a cursore usa un segnalibro che rappresenta “la posizione dopo l'ultimo elemento che ho visto”. La richiesta successiva continua da quella posizione in un ordine deterministico, quindi le inserzioni in cima e le cancellazioni nel mezzo non spostano il confine della pagina come fanno gli offset.
Usa un ordinamento deterministico con un tie-breaker, più comunemente (created_at, id) nella stessa direzione. created_at dà l'ordine utile per il prodotto, e id rende ogni posizione unica così non ripeti o salti elementi quando i timestamp coincidono.
Ordinare per updated_at può far sì che gli elementi saltino tra le pagine quando vengono modificati, rompendo l'aspettativa di “scroll stabile in avanti”. Se ti serve una vista “più recentemente aggiornato”, progetta l'interfaccia per il refresh e accetta il riordino invece di promettere uno scroll infinito stabile.
Restituisci un token opaco come next_cursor e fai sì che il client lo rimandi indietro senza modificarlo. Un approccio semplice è codificare in base64 il (created_at, id) dell'ultimo elemento, ma è importante trattarlo come valore opaco così puoi cambiare l'implementazione interna in seguito.
Costruisci la query successiva dai valori del cursore, non dal tentativo di “trova esattamente questa riga”. Se l'ultimo elemento è stato cancellato, il (created_at, id) memorizzato definisce ancora una posizione, quindi puoi continuare in sicurezza con un filtro “strettamente minore” (o “maggiore”) nello stesso ordine.
Usa una comparazione stretta e un tie-breaker unico, e prendi sempre il cursore dall'ultimo elemento che hai effettivamente restituito. La maggior parte dei bug di ripetizione nasce dall'usare <= invece di <, dall'omissione del tie-breaker o dal generare next_cursor dall'elemento sbagliato.
Scegli una regola chiara: il refresh carica elementi più recenti in cima, mentre lo scroll in avanti continua verso elementi più vecchi dal cursore esistente. Non mescolare le semantiche del “refresh” nello stesso flusso di cursore, altrimenti gli utenti vedranno riordini e penseranno che la lista sia inaffidabile.
Un cursore è valido solo per un ordinamento esatto e per un insieme di filtri. Se il client cambia modalità di ordinamento, query di ricerca o filtri, deve iniziare una nuova sessione di paginazione senza cursore e conservare i cursori separatamente per ogni stato della lista.
La paginazione a cursore è ottima per la navigazione sequenziale ma non per salti stabili a “pagina 20”, perché il dataset può cambiare. Se ti serve il salto, salta ad un anchor come “intorno a questo timestamp” o “a partire da questo id”, e poi pagina con cursori da lì.