¿Qué estrategia de paginación usas para una lista mobile respaldada por un dataset enorme?
La paginación por offset parece la más simple en una demo y se rompe silenciosamente en cuanto los datos reales empiezan a cambiar debajo de tu scroll infinito.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué estrategia de paginación usas para una lista mobile respaldada por un dataset enorme?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#La pregunta de paginación en realidad es una pregunta de "¿tus datos cambian?"
Toda lista mobile — un feed, un historial de chat, un catálogo de productos — eventualmente necesita cargar más datos de los que caben en una pantalla, y el enfoque ingenuo es OFFSET/LIMIT: la página 1 son las filas 0-19, la página 2 son las filas 20-39, y así sucesivamente. Es trivial de implementar y es exactamente lo que enseñan la mayoría de los tutoriales, lo cual también explica por qué es la trampa de paginación en la que caen la mayoría de los ingenieros sin darse cuenta hasta que QA reporta filas duplicadas o faltantes en un feed en vivo. La pregunta real que una estrategia de paginación tiene que responder no es "cómo obtengo las siguientes 20 filas," es "qué le pasa a la página 3 cuando alguien inserta o borra una fila mientras el usuario está desplazándose por la página 1."
#Por qué la paginación por offset se rompe con tráfico real
OFFSET 40 LIMIT 20 no significa "dame los elementos 40 al 59" en ningún sentido estable — significa "salta las primeras 40 filas de como sea que la tabla esté ordenada ahora mismo, y luego dame las siguientes 20," y si se borró una sola fila del frente de ese orden entre tu última petición y esta, cada fila se desplaza una posición hacia arriba: lo que era la fila 41 ahora es la fila 40, así que se sirve dos veces — una al final de la página anterior, otra al inicio de esta — mientras que lo que solía ser la fila 60 se salta por completo y nunca aparece. En una lista mobile de scroll infinito respaldada por datos en vivo (llegan posts nuevos, se borran elementos), esto no es un caso extremo raro, es el comportamiento por defecto. La paginación por offset también se vuelve más lenta a medida que crece el offset, porque la mayoría de las bases de datos todavía tienen que escanear y descartar cada fila saltada en vez de saltar directamente a la posición 40,000.
-- Paginación por offset: la página 500 significa escanear (y descartar) 5,000 filas,-- y el conjunto de resultados se desplaza debajo de ti si algo por encima cambia.SELECT id, title, created_atFROM articlesORDER BY created_at DESCOFFSET 5000 LIMIT 10;
#Paginación por cursor (keyset): retomar desde una posición, no desde un conteo
La paginación por cursor reemplaza "salta N filas" por "dame las filas que vienen después de esta fila específica," usando la propia clave de orden del último elemento como el punto de retomada en vez de un conteo de filas. Como la consulta filtra sobre un valor en lugar de contar N filas hacia atrás, el rendimiento se mantiene plano sin importar cuán profundo haya desplazado el usuario, y como nunca dice "salta 40," una eliminación anterior en la lista no puede causar un duplicado ni un elemento saltado — la siguiente página siempre se define en relación al último elemento que el cliente realmente vio.
-- Paginación keyset: retoma exactamente donde el cliente lo dejó,-- usando (created_at, id) como clave de orden estable y sin empates.SELECT id, title, created_atFROM articlesWHERE (created_at, id) < (:last_created_at, :last_id)ORDER BY created_at DESC, id DESCLIMIT 10;
El desempate por id importa más de lo que parece: created_at solo no es lo bastante único (dos filas pueden compartir un timestamp), así que sin una clave secundaria en el mismo índice, filas con timestamps idénticos pueden perderse o repetirse silenciosamente entre el límite de una página y la siguiente. El cursor en sí debería ser opaco para el cliente — codifica {created_at, id} como un string en base64 en vez de exponer valores de columna crudos, para que la API sea libre de cambiar su clave de orden subyacente más adelante sin romper a todos los clientes que parseaban el formato antiguo del cursor.
{"items": [{ "id": "a_9231", "title": "...", "createdAt": "2026-08-01T12:00:00Z" }],"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTAxVDEyOjAwOjAwWiIsImlkIjoiYV85MjMxIn0="}
#Conectándolo con el cliente
En Android, esto mapea directamente a PagingSource de Paging 3, indexado por el string de cursor opaco en vez de un índice de página entero — la función de carga pide "la página después de este cursor" y devuelve la siguiente, y Paging 3 se encarga de la distancia de prefetch, la deduplicación de peticiones y el diffing de Compose/RecyclerView encima de eso.
class ArticlesPagingSource(private val api: ArticlesApi,) : PagingSource<String, Article>() {override suspend fun load(params: LoadParams<String>): LoadResult<String, Article> {return try {val page = api.getArticles(cursor = params.key, limit = params.loadSize)LoadResult.Page(data = page.items,prevKey = null, // este feed solo carga hacia adelantenextKey = page.nextCursor,)} catch (e: IOException) {LoadResult.Error(e)} catch (e: HttpException) {LoadResult.Error(e)}}override fun getRefreshKey(state: PagingState<String, Article>): String? = null}
Para una lista offline-first, combina esto con un RemoteMediator que escriba cada página obtenida en una tabla local de Room y deje que el PagingSource lea realmente de esa tabla — la llamada de red se convierte en un rellenado en segundo plano en vez de ser lo que la UI renderiza directamente, que es lo que permite que la lista siga siendo desplazable y correcta incluso mientras una petición de página está en curso o fallando.
#Qué sacrificas, y cuándo ese sacrificio vale la pena
La paginación por cursor no es gratis: pierdes la capacidad de saltar directamente a "la página 12" o mostrar un conteo total confiable, porque todo el punto es que no existe un orden global estable por número de fila al cual saltar — solo puedes ir hacia adelante (o hacia atrás, si además capturas un prevCursor) desde una posición conocida. Ese es un sacrificio aceptable, incluso deseable, para un historial de chat, un feed social o una lista de resultados de búsqueda, todos consumidos secuencialmente y donde "cuántos resultados hay en total" rara vez es algo sobre lo que el usuario actúa. Es el sacrificio equivocado para algo como una tabla de datos con navegación por número de página y un control de "saltar a la página 40," donde la paginación por offset (a menudo sobre una réplica de lectura, o una vista materializada refrescada periódicamente para atenuar el problema de rendimiento) sigue siendo el ajuste más honesto para la UI que realmente estás construyendo.
🧠 Paginación por offset vs. por cursor
0/2 · 0/2 answered1. ¿Por qué la paginación por offset se vuelve más lenta a medida que crece el offset?
2. ¿Sobre qué filtra una consulta de paginación keyset/cursor en vez de un offset?