Cursor vs offset pagination per API rapide delle schermate admin
Scopri paginazione a cursore vs offset con un contratto API coerente per ordinamento, filtri e totali che mantiene le schermate admin veloci su web e mobile.

Perché la paginazione può far sembrare lente le schermate di amministrazione
Le schermate admin spesso iniziano come una semplice tabella: carica le prime 25 righe, aggiungi una casella di ricerca, fatto. Sembra istantaneo con qualche centinaio di record. Poi il dataset cresce e la stessa schermata inizia a balbettare.
Il problema di solito non è l'interfaccia. È ciò che l'API deve fare prima di restituire la pagina 12 con ordinamenti e filtri applicati. Man mano che la tabella cresce, il backend impiega più tempo a trovare le corrispondenze, contarle e saltare i risultati precedenti. Se ogni clic scatena una query più pesante, la schermata sembra «pensare» invece di rispondere.
Lo noti quasi sempre negli stessi punti: i cambi pagina rallentano col tempo, l'ordinamento diventa lento, la ricerca è incoerente tra le pagine e lo scroll infinito carica a scatti (veloce, poi all'improvviso lento). Nei sistemi molto attivi potresti anche vedere duplicati o righe mancanti quando i dati cambiano tra le richieste.
Le UI web e mobile spingono la paginazione in direzioni diverse. Una tabella admin web incentiva a saltare a una pagina specifica e ordinare per molte colonne. Le schermate mobile solitamente usano una lista infinita che carica il blocco successivo e gli utenti si aspettano che ogni richiesta sia altrettanto veloce. Se la tua API è costruita solo attorno ai numeri di pagina, il mobile spesso ne soffre. Se è costruita solo attorno a next/after, le tabelle web possono sembrare limitate.
L'obiettivo non è solo restituire 25 elementi. È una paginazione veloce e prevedibile che resta stabile mentre i dati crescono, con regole che funzionano allo stesso modo per tabelle e liste infinite.
Nozioni di base sulla paginazione di cui l'UI dipende
La paginazione è lo spezzare un lungo elenco in porzioni più piccole così che la schermata possa caricare e renderizzare rapidamente. Invece di chiedere all'API tutti i record, l'UI richiede la fetta successiva di risultati.
Il controllo più importante è la dimensione della pagina (spesso chiamata limit). Pagine più piccole solitamente sembrano più veloci perché il server fa meno lavoro e l'app disegna meno righe. Ma pagine troppo piccole possono sembrare scattose perché gli utenti devono cliccare o scorrere più spesso. Per molte tabelle amministrative, 25–100 elementi è una fascia pratica, con il mobile che di solito preferisce l'estremità inferiore.
Un ordine di ordinamento stabile conta più di quanto molti team si aspettino. Se l'ordine può cambiare tra le richieste, gli utenti vedono duplicati o righe mancanti mentre navigano. Un ordinamento stabile significa di solito ordinare per un campo primario (come created_at) più un tie-breaker (come id). Questo vale sia che tu usi offset sia cursore.
Dal punto di vista del client, una risposta paginata dovrebbe includere gli elementi, un suggerimento per la pagina successiva (numero di pagina o token cursore) e solo i conteggi che l'UI realmente necessita. Alcune schermate richiedono un totale esatto per “1-50 di 12.340”. Altre hanno bisogno solo di has_more.
Paginazione offset: come funziona e dove fa male
La paginazione offset è l'approccio classico della pagina N. Il client chiede un numero fisso di righe e dice all'API quante righe saltare all'inizio. La vedrai come limit e offset, o come page e pageSize che il server converte in un offset.
Una richiesta tipica è così:
GET /tickets?limit=50&offset=950- “Dammi 50 ticket, saltando i primi 950.”
Si adatta ai bisogni amministrativi comuni: saltare alla pagina 20, scansionare record più vecchi o esportare una lunga lista a blocchi. È anche facile da descrivere internamente: “Guarda la pagina 3 e la vedrai.”
Il problema emerge nelle pagine profonde. Molti database devono comunque scorrere le righe saltate prima di restituire la tua pagina, specialmente quando l'ordinamento non è supportato da un indice efficiente. La pagina 1 può essere veloce, ma la pagina 200 può diventare notevolmente più lenta, ed è proprio questo che fa sembrare le schermate admin lente quando gli utenti scorrono o saltano tra le pagine.
L'altro problema è la coerenza quando i dati cambiano. Immagina un responsabile support che apre la pagina 5 dei ticket ordinati per più recenti. Mentre guarda, arrivano nuovi ticket o vengono cancellati ticket più vecchi. Le inserzioni possono spostare gli elementi in avanti (duplicati tra le pagine). Le cancellazioni possono far retrocedere gli elementi (record che scompaiono dal percorso di navigazione dell'utente).
La paginazione offset può comunque andar bene per tabelle piccole, dataset stabili o esportazioni una tantum. Su tabelle grandi e attive, i casi limite emergono rapidamente.
Paginazione a cursore: come funziona e perché resta stabile
La paginazione a cursore usa un cursore come segnalibro. Invece di dire “dammi la pagina 7”, il client dice “continua dopo questo elemento esatto”. Il cursore solitamente codifica i valori di ordinamento dell'ultimo elemento (per esempio created_at e id) così che il server possa riprendere dal punto giusto.
La richiesta è di solito solo:
limit: quante voci restituirecursor: un token opaco dalla risposta precedente (spesso chiamatoafter)
La risposta restituisce gli elementi più un nuovo cursore che punta alla fine di quella fetta. La differenza pratica è che i cursori non chiedono al database di contare e saltare righe. Gli chiedono di iniziare da una posizione nota.
Per questo la paginazione a cursore resta veloce per liste che scorrono in avanti. Con un buon indice, il database può saltare a “elementi dopo X” e poi leggere le successive limit righe. Con gli offset, il server spesso deve scansionare (o almeno saltare) sempre più righe man mano che l'offset cresce.
Per il comportamento UI, la paginazione a cursore rende naturale il “Next”: prendi il cursore restituito e lo rimandi nella richiesta successiva. Il “Previous” è opzionale e più complicato. Alcune API supportano un cursore before, altre leggono in reverse e capovolgono i risultati.
Quando scegliere cursore, offset o un ibrido
La scelta parte da come le persone usano effettivamente la lista.
La paginazione a cursore si adatta meglio quando gli utenti si muovono soprattutto in avanti e la velocità è prioritaria: activity log, chat, ordini, ticket, audit trail e la maggior parte dello scroll infinito mobile. Si comporta anche meglio quando nuove righe sono inserite o eliminate mentre qualcuno sta navigando.
La paginazione offset ha senso quando gli utenti saltano frequentemente: tabelle amministrative classiche con numeri di pagina, vai-a-pagina e navigazione avanti/indietro rapida. È semplice da spiegare, ma può rallentare su dataset grandi ed essere meno stabile quando i dati cambiano sotto gli utenti.
Un modo pratico per decidere:
- Scegli cursore quando l'azione principale è “next, next, next”.
- Scegli offset quando “vai a pagina N” è un requisito reale.
- Tratta i totali come opzionali. I conteggi accurati possono essere costosi su tabelle enormi.
Gli ibridi sono comuni. Un approccio è usare cursore per next/prev per velocità, più una modalità opzionale di salto pagina per sottoinsiemi piccoli e filtrati dove gli offset restano veloci. Un altro è recuperare con cursore e calcolare numeri di pagina basandosi su uno snapshot cacheato, così la tabella sembra familiare senza trasformare ogni richiesta in lavoro pesante.
Un contratto API coerente che funziona su web e mobile
Le UI admin sembrano più veloci quando ogni endpoint lista si comporta allo stesso modo. L'UI può cambiare (tabella web con numeri di pagina, lista infinita mobile), ma il contratto API dovrebbe restare stabile così non devi reimparare le regole di paginazione per ogni schermata.
Un contratto pratico ha tre parti: righe, stato di paginazione e totali opzionali. Mantieni i nomi identici tra gli endpoint (tickets, users, orders), anche se la modalità di paging sottostante differisce.
Ecco una forma di risposta che funziona bene sia per il web sia per il mobile:
{
"data": [ { "id": "...", "createdAt": "..." } ],
"page": {
"mode": "cursor",
"limit": 50,
"nextCursor": "...",
"prevCursor": null,
"hasNext": true,
"hasPrev": false
},
"totals": {
"count": 12345,
"filteredCount": 120
}
}
Alcuni dettagli che lo rendono riutilizzabile:
page.modedice al client cosa sta facendo il server senza cambiare i nomi dei campi.limitè sempre la dimensione pagina richiesta.nextCursoreprevCursorsono presenti anche se uno ènull.totalsè opzionale. Se è costoso, restituirlo solo quando il client lo richiede.
Una tabella web può ancora mostrare “Pagina 3” mantenendo il proprio indice pagina e chiamando ripetutamente l'API. Una lista mobile può ignorare i numeri di pagina e richiedere semplicemente il prossimo blocco.
Se stai costruendo sia web che mobile admin con AppMaster, un contratto stabile come questo paga rapidamente. Lo stesso comportamento di lista può essere riutilizzato tra le schermate senza logiche di paginazione personalizzate per ogni endpoint.
Regole di ordinamento che mantengono stabile la paginazione
L'ordinamento è il punto in cui la paginazione di solito si rompe. Se l'ordine può cambiare tra le richieste, gli utenti vedono duplicati, gap o righe “mancanti”.
Fai dell'ordinamento un contratto, non un suggerimento. Pubblica i campi e le direzioni di ordinamento permessi e rifiuta tutto il resto. Questo mantiene l'API prevedibile e impedisce ai client di richiedere ordinamenti lenti che in sviluppo sembrano innocui.
Un ordinamento stabile richiede un tie-breaker unico. Se ordini per created_at e due record hanno la stessa marca temporale, aggiungi id (o un altro campo unico) come ultima chiave di ordinamento. Senza di esso, il database è libero di restituire valori uguali in qualsiasi ordine.
Regole pratiche che reggono:
- Consenti l'ordinamento solo su campi indicizzati e ben definiti (per esempio
created_at,updated_at,status,priority). - Includi sempre un tie-breaker unico come chiave finale (per esempio
id ASC). - Definisci un ordinamento predefinito (per esempio
created_at DESC, id DESC) e mantienilo coerente tra i client. - Documenta come vengono trattati i null (per esempio “nulls last” per date e numeri).
L'ordinamento guida anche la generazione del cursore. Un cursore dovrebbe codificare i valori di ordinamento dell'ultimo elemento in ordine, incluso il tie-breaker, così la pagina successiva può interrogare “after” quella tupla. Se l'ordinamento cambia, i cursori vecchi diventano invalidi. Tratta i parametri di ordinamento come parte del contratto del cursore.
Filtri e totali senza rompere il contratto
I filtri dovrebbero sentirsi separati dalla paginazione. L'UI sta dicendo “fammi vedere un diverso insieme di righe” e solo allora chiede “paginaci dentro”. Se mescoli i campi di filtro nel token di paginazione o tratti i filtri come opzionali e non validati, ottieni comportamenti difficili da debug: pagine vuote, duplicati o un cursore che improvvisamente punta a un dataset diverso.
Una regola semplice: i filtri vivono in parametri di query normali (o nel body per POST) e il cursore è opaco e valido solo per quella esatta combinazione di filtri e ordinamento. Se l'utente cambia qualsiasi filtro (status, intervallo di date, assegnatario), il client dovrebbe scartare il vecchio cursore e ricominciare dall'inizio.
Sii severo su quali filtri sono permessi. Protegge le prestazioni e mantiene il comportamento prevedibile:
- Rifiuta campi filtro sconosciuti (non ignorarli silenziosamente).
- Valida tipi e range (date, enum, ID).
- Limita filtri troppo ampi (per esempio max 50 ID in una lista IN).
- Applica gli stessi filtri ai dati e ai totali (niente numeri non corrispondenti).
I totali sono dove molte API rallentano. I conteggi esatti possono essere costosi su tabelle grandi, specialmente con più filtri. In genere hai tre opzioni: esatto, stimato o nessuno. L'esatto è ottimo per dataset piccoli o quando gli utenti hanno davvero bisogno di “mostrando 1-25 di 12.431”. Lo stimato è spesso sufficiente per le schermate admin. Nessuno va bene quando ti basta “Carica altro”.
Per evitare di rallentare ogni richiesta, rendi i totali opzionali: calcolali solo quando il client lo chiede (per esempio con una flag come includeTotal=true), cacheali brevemente per set di filtri, o restituiscili solo sulla prima pagina.
Passo dopo passo: progettare e implementare l'endpoint
Inizia con dei default. Un endpoint lista necessita di un ordine stabile, più un tie-breaker per le righe che condividono lo stesso valore. Per esempio: createdAt DESC, id DESC. Il tie-breaker (id) previene duplicati e gap quando vengono aggiunti nuovi record.
Definisci una sola forma di richiesta e mantienila noiosa. I parametri tipici sono limit, cursor (o offset), sort e filters. Se supporti entrambe le modalità, rendile mutuamente esclusive: o il client invia cursor, o invia offset, ma non entrambi.
Mantieni un contratto di risposta coerente così web e mobile possono condividere la stessa logica di lista:
items: la pagina di recordnextCursor: il cursore per ottenere la pagina successiva (onull)hasMore: booleano così l'UI decide se mostrare “Carica altro”total: totale dei record corrispondenti (nulla meno che non sia richiesto se il conteggio è costoso)
L'implementazione è dove i due approcci divergono.
Le query offset sono solitamente ORDER BY ... LIMIT ... OFFSET ..., che possono rallentare su tabelle grandi.
Le query cursore usano condizioni di seek basate sull'ultimo elemento: “dammi elementi dove (createdAt, id) è minore dell'ultimo (createdAt, id)”. Questo mantiene le prestazioni più stabili perché il database può usare gli indici.
Prima di spedire, aggiungi guardrail:
- Cap sul
limit(per esempio, max 100) e un default. - Valida
sortcontro una allowlist. - Valida i filtri per tipo e rifiuta chiavi sconosciute.
- Rendi il
cursoropaco (codifica gli ultimi valori di ordinamento) e rifiuta cursori malformati. - Decidi come richiedere
total.
Testa con dati che cambiano sotto di te. Crea e cancella record tra le richieste, aggiorna i campi che influenzano l'ordinamento e verifica di non vedere duplicati o righe mancanti.
Esempio: lista tickets che resta veloce su web e mobile
Un team di support apre una schermata admin per rivedere i ticket più recenti. Hanno bisogno che la lista sembri istantanea, anche mentre arrivano nuovi ticket e gli agenti aggiornano quelli vecchi.
Sul web, l'UI è una tabella. L'ordinamento di default è per updated_at (i più recenti prima) e il team filtra spesso per Open o Pending. Lo stesso endpoint può supportare entrambe le azioni con un ordinamento stabile e un token cursore.
GET /tickets?status=open&sort=-updated_at&limit=50&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wMS0yNVQxMTo0NTo0MloiLCJpZCI6IjE2OTMifQ==
La risposta resta prevedibile per l'UI:
{
"items": [{"id": 1693, "subject": "Login issue", "status": "open", "updated_at": "2026-01-25T11:45:42Z"}],
"page": {"next_cursor": "...", "has_more": true},
"meta": {"total": 128}
}
Sul mobile, lo stesso endpoint alimenta lo scroll infinito. L'app carica 20 ticket alla volta, poi invia next_cursor per prendere il batch successivo. Niente logica di numeri di pagina e meno sorprese quando i record cambiano.
La chiave è che il cursore codifica l'ultima posizione vista (per esempio updated_at più id come tie-breaker). Se un ticket viene aggiornato mentre l'agente scorre, può spostarsi verso l'alto al prossimo refresh, ma non causerà duplicati o gap nel feed già scansionato.
I totali sono utili ma costosi su dataset grandi. Una regola semplice è restituire meta.total solo quando l'utente applica un filtro (come status=open) o lo richiede esplicitamente.
Errori comuni che causano duplicati, gap e lag
La maggior parte dei bug di paginazione non sono nel database. Nascono da piccole decisioni API che sembrano accettabili in fase di test, poi si rompono quando i dati cambiano tra le richieste.
La causa più comune di duplicati (o righe mancanti) è ordinare su un campo non unico. Se ordini per created_at e due elementi condividono lo stesso timestamp, l'ordine può invertirsi tra le richieste. La soluzione è semplice: aggiungi sempre un tie-breaker stabile, di solito la chiave primaria, e tratta l'ordinamento come una coppia tipo (created_at desc, id desc).
Un altro problema comune è permettere ai client di richiedere qualsiasi dimensione pagina. Una richiesta grande può far schizzare CPU, memoria e tempi di risposta, rallentando ogni schermata admin. Scegli un default sensato e un massimo rigido, e restituisci un errore quando il client chiede di più.
Anche i totali possono fare danni. Contare tutte le righe corrispondenti a ogni richiesta può diventare la parte più lenta dell'endpoint, specialmente con filtri. Se l'UI ha bisogno dei totali, fetchali solo quando lo chiede (o restituisci una stima) ed evita di bloccare lo scrolling su un conteggio completo.
Errori che più spesso creano gap, duplicati e lag:
- Ordinare senza un tie-breaker unico (ordine instabile)
- Dimensioni pagina illimitate (sovraccarico server)
- Restituire i totali ogni volta (query lente)
- Mescolare regole offset e cursore in un unico endpoint (comportamento client confuso)
- Riutilizzare lo stesso cursore quando filtri o ordinamento cambiano (risultati errati)
Reimposta la paginazione ogni volta che cambiano filtri o ordinamento. Tratta un nuovo filtro come una nuova ricerca: pulisci il cursore/offset e ricomincia dalla prima pagina.
Checklist rapida prima del deploy
Esegui questo controllo con API e UI affiancati. La maggior parte dei problemi nasce nel contratto tra la schermata lista e il server.
- L'ordinamento di default è stabile e include un tie-breaker unico (per esempio
created_at DESC, id DESC). - I campi e le direzioni di ordinamento sono in whitelist.
- È imposto un max page size, con un default sensato.
- I token cursore sono opachi e i cursori non validi falliscono in modo prevedibile.
- Qualsiasi cambiamento di filtro o ordinamento resetta lo stato di paginazione.
- Il comportamento dei totali è esplicito: esatto, stimato o omesso.
- Lo stesso contratto supporta sia una tabella sia uno scroll infinito senza casi speciali.
Prossimi passi: standardizza le tue liste e mantienile coerenti
Scegli un elenco amministrativo che la gente usa ogni giorno e rendilo il tuo gold standard. Una tabella trafficata come Tickets, Orders o Users è un buon punto di partenza. Quando quell'endpoint sarà veloce e prevedibile, copia lo stesso contratto attraverso il resto delle schermate admin.
Scrivi il contratto, anche se è breve. Sii esplicito su cosa l'API accetta e cosa restituisce così il team UI non indovini e accidentalmente inventi regole diverse per ogni endpoint.
Un semplice standard da applicare a ogni endpoint lista:
- Sort consentiti: nomi campo esatti, direzione e un default chiaro (più un tie-breaker come
id). - Filtri consentiti: quali campi possono essere filtrati, formati dei valori e cosa succede su filtri invalidi.
- Comportamento dei totali: quando restituisci un conteggio, quando ritorni “unknown” e quando lo salti.
- Forma della risposta: chiavi coerenti (
items, info di paging, sort/filters applicati, totals). - Regole di errore: codici di stato coerenti e messaggi di validazione leggibili.
Se stai costruendo queste schermate admin con AppMaster, conviene standardizzare il contratto di paginazione presto. Puoi riusare lo stesso comportamento lista tra la web app e le app native, e risparmi tempo a inseguire i casi limite di paginazione in seguito.
FAQ
La paginazione offset usa limit più offset (o page/pageSize) per saltare righe, quindi le pagine profonde spesso diventano più lente perché il database deve scorrere più record. La paginazione a cursore usa un token after basato sui valori di ordinamento dell'ultimo elemento, permettendo di saltare a una posizione nota e restare veloce mentre si procede in avanti.
Perché la pagina 1 è di solito economica, ma la pagina 200 costringe il database a saltare un gran numero di righe prima di restituire qualcosa. Se poi applichi ordinamenti e filtri, il lavoro cresce, quindi ogni clic sembra una query pesante anziché una semplice lettura.
Usa sempre un ordinamento stabile con un tie-breaker unico, ad esempio created_at DESC, id DESC o updated_at DESC, id DESC. Senza il tie-breaker, record con la stessa marca temporale possono cambiare ordine tra le richieste, causando duplicati o righe “mancanti”.
Preferisci la paginazione a cursore per elenchi dove gli utenti avanzano principalmente e la velocità è importante: activity log, ticket, ordini e infinite scroll mobile. Resta consistente quando vengono inserite o eliminate nuove righe perché il cursore ancora punta a una posizione esatta vista in precedenza.
L'offset è indicato quando la UI richiede davvero la funzione “vai a pagina N” e gli utenti saltano spesso tra le pagine. Va bene anche per tabelle piccole o dataset stabili, dove il rallentamento alle pagine profonde e lo spostamento dei risultati non sono un problema.
Mantieni una forma di risposta unica tra gli endpoint e includi gli elementi, lo stato di paginazione e totali opzionali. Un default pratico è restituire items, un oggetto page (con limit, nextCursor/prevCursor o offset) e una bandiera leggera come hasNext così sia le tabelle web sia le liste mobile possono riusare la stessa logica client.
Perché un COUNT(*) preciso su dataset grandi e filtrati può diventare la parte più lenta della richiesta e rendere ogni cambio pagina sensibile. Un comportamento più sicuro è rendere i totali opzionali, restituirli solo su richiesta o inviare has_more quando l'UI necessita solo del “Carica altro”.
Tratta i filtri come parte del dataset e considera il cursore valido solo per quella combinazione esatta di filtri e ordinamento. Se l'utente cambia un filtro o l'ordinamento, azzera la paginazione e ricomincia dalla prima pagina; riutilizzare un vecchio cursore dopo modifiche è un modo comune per ottenere pagine vuote o risultati confusi.
Convalida e whitelist degli ordinamenti; rifiuta qualsiasi ordinamento non permesso in modo che i client non richiedano accidentalmente ordini lenti o instabili. Prediligi campi indicizzati e aggiungi sempre un tie-breaker unico come id per mantenere l'ordine deterministico tra le richieste.
Applica un limit massimo, valida filtri e parametri di ordinamento, e rendi i token cursore opachi e rigorosamente validati. Se costruisci schermate amministrative con AppMaster, mantenere queste regole coerenti tra tutti gli endpoint di elenco rende più semplice riusare tabelle e scroll infinito senza correzioni personalizzate per ogni schermata.


