Email copiado — support@tuurt.com
Cargando experiencia
kotlin · 19 de septiembre de 2026 · 6 min

Paginación con Paging 3 en Android: evitar páginas duplicadas cuando el backend no ofrece un cursor estable

Paginar con Paging 3 y RemoteMediator sobre un offset numérico falla cuando el backend recibe inserciones mientras el usuario hace scroll. Vemos por qué un cursor basado en id resuelve el problema y qué salió mal al intentar deduplicar en el cliente.

Por Equipo Tuurt

Paginación con Paging 3 en Android: evitar páginas duplicadas cuando el backend no ofrece un cursor estable

Paging 3 resuelve, de fábrica, buena parte de lo tedioso de paginar una lista larga en Android: mantiene el estado de carga, expone LoadState para mostrar spinners y errores, y se integra con RecyclerView a través de PagingDataAdapter sin que haya que escribir a mano la lógica de "cuándo pedir la siguiente página". Lo que Paging 3 no resuelve por nosotros es qué significa "la siguiente página" cuando el backend la define con un offset numérico y la tabla que hay detrás recibe inserciones mientras el usuario sigue haciendo scroll. Ese desajuste nos dio duplicados intermitentes en producción durante semanas antes de que entendiéramos que el problema no estaba en el cliente.

Cómo se conecta RemoteMediator con el backend

En una lista respaldada por red y caché local, RemoteMediator es el componente que decide cuándo pedir más datos al backend y cómo los vuelca en la base de datos local que PagingSource termina exponiendo a la UI. Una implementación típica luce así:

class PedidosRemoteMediator(
    private val api: PedidosApi,
    private val db: AppDatabase,
) : RemoteMediator<Int, PedidoEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PedidoEntity>,
    ): MediatorResult {
        return try {
            val response = when (loadType) {
                LoadType.REFRESH -> api.getPedidos(offset = 0, limit = state.config.pageSize)
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    val offset = state.pages.sumOf { it.data.size }
                    api.getPedidos(offset = offset, limit = state.config.pageSize)
                }
            }

            db.withTransaction {
                if (loadType == LoadType.REFRESH) db.pedidoDao().clearAll()
                db.pedidoDao().insertAll(response.items)
            }

            MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
        } catch (e: IOException) {
            MediatorResult.Error(e)
        } catch (e: HttpException) {
            MediatorResult.Error(e)
        }
    }
}

El detalle que importa está en la rama APPEND: el offset que se le pasa al backend se calcula sumando cuántos elementos ya se cargaron en páginas anteriores. Es la forma más directa de implementar paginación por offset, y funciona sin sorpresas mientras la tabla de origen sea de solo lectura desde la perspectiva del usuario que está paginando. El problema aparece en cuanto esa tabla recibe inserciones nuevas en el mismo rango de tiempo en que alguien está haciendo scroll.

Por qué el offset se corre con inserciones concurrentes

Un offset numérico responde a la pregunta "dame los elementos entre la posición 40 y la 60 según el orden actual". Si entre la primera página y la segunda se insertan cinco pedidos nuevos que, por el criterio de orden del backend, quedan antes de la posición 40 -por ejemplo, porque el listado ordena por fecha de creación descendente y esos pedidos son más recientes que todo lo ya cargado-, el offset 40 ya no apunta a lo mismo que apuntaba cuando se pidió la primera página. Los cinco elementos que antes estaban en las posiciones 35 a 39 ahora están en las posiciones 40 a 44, y la segunda petición con offset=40 los vuelve a traer, duplicados, mezclados con elementos genuinamente nuevos.

Esto no es un caso raro que solo ocurre bajo carga extrema. En una lista de pedidos, notificaciones o cualquier entidad que se crea con frecuencia mientras alguien tiene la pantalla abierta, es el caso normal. El síntoma que vimos en producción era intermitente y difícil de reproducir en el emulador precisamente porque depende de que llegue una inserción real en la ventana de tiempo entre dos peticiones de página, algo que no ocurre con datos de prueba estáticos.

La corrección: cursor basado en un id ordenado

La solución no está en el cliente, sino en cambiar qué le pedimos al backend. En lugar de un offset numérico que depende de una foto instantánea del total de filas, se pide "el siguiente lote después de este id", usando una columna con orden estable -típicamente la clave primaria autoincremental o un timestamp con suficiente resolución como para no tener empates- como cursor:

LoadType.APPEND -> {
    val lastItem = state.lastItemOrNull()
        ?: return MediatorResult.Success(endOfPaginationReached = true)
    api.getPedidos(cursorId = lastItem.id, limit = state.config.pageSize)
}

Del lado del backend, la consulta correspondiente reemplaza el OFFSET por una condición sobre el cursor:

SELECT * FROM pedidos
WHERE id < :cursor_id
ORDER BY id DESC
LIMIT :limit;

La diferencia es que esta consulta no depende de cuántas filas hay antes de un punto arbitrario, sino de un valor concreto que ya vimos. Una inserción nueva con un id mayor al cursor simplemente no entra en el resultado, sin importar cuántas filas se hayan insertado entre una petición y la siguiente. El cliente nunca vuelve a ver un elemento que ya cargó, porque la condición de corte no se mueve cuando cambia el tamaño de la tabla.

Vale aclarar la condición para que esto funcione: la columna usada como cursor tiene que tener orden total y estable en el tiempo -un id autoincremental cumple esto de forma natural- y el ORDER BY de la consulta tiene que coincidir exactamente con esa columna. Si el listado necesita ordenar por otro criterio, como fecha de actualización, el cursor tiene que ser compuesto (fecha, id) para desempatar los casos en que dos filas comparten el mismo timestamp, porque un cursor sobre una columna con valores repetidos reintroduce el mismo problema del offset: dos elementos con el mismo valor de cursor pueden quedar a caballo entre dos páginas de forma inconsistente.

Lo que no funcionó: deduplicar en el cliente

Antes de tocar el backend, intentamos resolver esto del lado del cliente: guardar un Set<Long> con los ids ya vistos en el ViewModel y filtrar cualquier elemento repetido antes de que llegara al PagingDataAdapter. La idea parecía razonable -si el problema es que llegan duplicados, basta con no mostrarlos dos veces- pero rompió más de lo que arregló.

El primer problema fue de scroll: Paging 3 calcula la posición de scroll y el prefetch de páginas siguientes asumiendo que el tamaño de cada página que reporta RemoteMediator corresponde al número real de elementos nuevos. Si filtrábamos duplicados después de que RoomDatabase ya los había insertado, el PagingSource seguía reportando el conteo completo -duplicados incluidos- mientras el adapter mostraba menos elementos de los que Paging 3 pensaba que había, y eso descalibraba el prefetch: a veces pedía la siguiente página demasiado tarde, dejando al usuario con la rueda de carga visible más tiempo del esperado.

El segundo problema fue más de fondo: filtrar en el cliente trata el síntoma sin tocar la causa, así que cualquier otra pantalla que usara el mismo endpoint -una búsqueda con paginación, un export- tenía que reimplementar el mismo Set de deduplicación por su cuenta, con el riesgo de que alguien se olvidara. Cuando finalmente cambiamos el backend a cursor por id, eliminamos por completo la deduplicación del cliente: ya no hacía falta, y mantenerla habría escondido cualquier problema real que volviera a aparecer en el futuro en lugar de dejarlo visible.

Cuándo el offset numérico sigue siendo razonable

No toda paginación necesita cursor. Si la tabla de origen es efectivamente de solo lectura durante la sesión de paginación -un reporte generado una vez y consultado después, un export histórico que no recibe escrituras nuevas- el offset numérico es más simple de implementar y de depurar, y no tiene ninguna desventaja real porque la condición que rompe el cursor -inserciones concurrentes que cambian las posiciones- simplemente no se da. La pregunta que usamos ahora antes de elegir uno u otro es directa: ¿puede insertarse una fila nueva en el rango que ya se paginó mientras el usuario todavía está paginando? Si la respuesta es sí, offset numérico no es una opción, es un bug a la espera de que alguien haga scroll en el momento equivocado.

android kotlin paging-3 api
← Volver al blog