Vinicius Aguiar
Frontend

De 6,7 MB a 19 KB: paginación cursor-based y virtualización en un inbox React

23 de jul. de 2026 · 12 min de lectura

Todo SaaS B2B tiene una pantalla donde ocurre el producto entero. En el caso de este post — una plataforma de mensajería usada por equipos comerciales — esa pantalla es el inbox: la lista de conversaciones que los usuarios mantienen abierta todo el día. En workspaces maduros, esa lista acumula ~20.000 conversaciones. Y la versión original las cargaba todas de una vez.

Este post documenta la reconstrucción de esa pantalla desde el punto de vista de frontend: por qué la paginación offset-based no alcanzaba, cómo quedó el contrato de API cursor-based, la implementación con React Query y TanStack Virtual, y los trade-offs asumidos. El resultado: el payload cayó de ~6,7 MB a ~19 KB por request (~400x), y los bloqueos de interfaz desaparecieron.

Payload por request antes y después: 6,7 MB contra 19 KB (~400x), con comparación a escala realPayload por request antes y después: 6,7 MB contra 19 KB (~400x), con comparación a escala real

El problema: cargar todo de una vez

El síntoma era visible a simple vista: abrir el inbox en un workspace grande tomaba varios segundos, y la interfaz se congelaba durante el renderizado. DevTools contaba el resto de la historia: una única respuesta JSON de ~6,7 MB, y el Performance profiler mostrando long tasks bloqueando el main thread mientras React montaba miles de filas.

La causa raíz era un endpoint sin paginación: el listado devolvía todas las conversaciones del workspace, y el cliente renderizaba la lista entera. Ese diseño tiene una propiedad traicionera: empeora silenciosamente con el éxito del producto. Con 500 conversaciones, nadie lo nota. Con 20.000, la pantalla más importante del producto se vuelve la más lenta.

Había dos costos distintos mezclados, y vale la pena separarlos porque las soluciones son diferentes: el costo de red y serialización (descargar y parsear 6,7 MB) y el costo de renderizado (mantener ~20.000 componentes montados en el DOM). La paginación resuelve el primero. Solo la virtualización resuelve el segundo.

Por qué no offset-based

La primera idea obvia sería ?page=3&limit=50. Para un inbox, la paginación offset-based falla en dos puntos:

  • Costo de lectura crecienteOFFSET 10000 obliga a la base de datos a recorrer y descartar 10.000 filas antes de devolver las 50 siguientes. Las páginas se vuelven más lentas cuanto más profundo navega el usuario.
  • Inestabilidad bajo escrituras concurrentes — un inbox recibe mensajes nuevos todo el tiempo, y cada inserción desplaza el offset: los ítems aparecen duplicados o desaparecen entre una página y la siguiente. Exactamente el tipo de bug intermitente que nadie logra reproducir.

La paginación cursor-based (keyset) resuelve ambos: en vez de contar filas, cada página parte de un puntero estable al último registro cargado. El costo de lectura se mantiene constante y las inserciones nuevas no desplazan nada.

El contrato de la API

El cursor es opaco para el cliente: una string codificada que solo el backend sabe interpretar — en la práctica, el par ordenable (updated_at, id), con el id desempatando registros con el mismo timestamp. El contrato que consume el frontend:

// GET /conversations?workspaceId=...&cursor=...&limit=50
type ConversationsPage = {
  items: ConversationPreview[]
  nextCursor: string | null // null => last page
}

// The list never receives the full conversation:
// only the fields the row actually renders
type ConversationPreview = {
  id: string
  contactName: string
  lastMessageSnippet: string
  lastMessageAt: string
  unreadCount: number
  status: "open" | "closed" | "snoozed"
}

Un detalle con impacto desproporcionado: la lista no recibe la conversación completa, solo los campos que la fila muestra. El tipo ConversationPreview existe para eso — impedir que el payload del listado crezca junto con el modelo completo de conversación. El payload shaping y la paginación, combinados, son los que producen los ~19 KB por página.

Data fetching con React Query

useInfiniteQuery fue hecho exactamente para este formato: cada página declara de dónde viene la siguiente vía getNextPageParam, y la biblioteca se encarga del cache, la deduplicación y los estados de carga:

function useConversations(workspaceId: string) {
  return useInfiniteQuery({
    queryKey: ["conversations", workspaceId],
    queryFn: ({ pageParam }) =>
      fetchConversations({ workspaceId, cursor: pageParam, limit: 50 }),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  })
}

// Pages arrive as an array of arrays;
// the list consumes a single flat array
const allRows = data ? data.pages.flatMap((page) => page.items) : []

El punto central de este diseño es que la acumulación ocurre en el cache, no en el DOM: navegar profundo en el inbox agrega páginas a React Query, pero — como muestra la próxima sección — el número de elementos renderizados sigue siendo el mismo.

Virtualización con TanStack Virtual

La paginación sola reduce el payload, no el costo de renderizado: si el usuario hace scroll suficiente, las páginas acumuladas reconstruyen el problema original, fila por fila, dentro del DOM. La respuesta es windowing — renderizar solo las filas visibles en el viewport (más un margen de overscan), posicionadas de forma absoluta dentro de un contenedor que mantiene la altura total de la lista, lo que conserva honesta la barra de scroll.

Diagrama de virtualización: de las ~20.000 conversaciones, solo las filas dentro del viewport (más el overscan) existen en el DOMDiagrama de virtualización: de las ~20.000 conversaciones, solo las filas dentro del viewport (más el overscan) existen en el DOM

Con @tanstack/react-virtual, el useVirtualizer se vuelve también el disparador natural de la paginación: cuando el último ítem virtual se acerca al final de la lista cargada, se pide la próxima página:

const parentRef = useRef<HTMLDivElement>(null)

const virtualizer = useVirtualizer({
  count: hasNextPage ? allRows.length + 1 : allRows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 88, // row height estimate; measureElement refines it
  overscan: 8,
})

const virtualItems = virtualizer.getVirtualItems()

useEffect(() => {
  const last = virtualItems.at(-1)
  if (!last) return
  if (last.index >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
    fetchNextPage()
  }
}, [virtualItems, allRows.length, hasNextPage, isFetchingNextPage, fetchNextPage])

return (
  <div ref={parentRef} className="h-full overflow-auto">
    <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
      {virtualItems.map((row) => (
        <ConversationRow
          key={allRows[row.index]?.id ?? "loader"}
          ref={virtualizer.measureElement}
          data-index={row.index}
          conversation={allRows[row.index]}
          style={{
            position: "absolute",
            top: 0,
            transform: `translateY(${row.start}px)`,
          }}
        />
      ))}
    </div>
  </div>
)

Dos decisiones en ese fragmento merecen comentario. El count incluye una fila extra cuando hay próxima página — se convierte en el esqueleto de carga al final de la lista. Y measureElement mide la altura real de cada fila después de montada, porque las filas de un inbox no tienen altura fija (snippets de una o dos líneas, badges, estados). El estimateSize solo necesita ser lo bastante bueno para el primer paint.

Resultados

  • Payload por request: de ~6,7 MB a ~19 KB (~400x)
  • Fin de los bloqueos de interfaz — con un DOM estable de ~10–15 filas, las long tasks de renderizado desaparecieron del profiler
  • Abrir el inbox dejó de tomar varios segundos, incluso en los workspaces más grandes
  • El costo de la pantalla dejó de crecer con el tamaño del workspace: 500 o 20.000 conversaciones, el mismo trabajo por frame

Trade-offs asumidos

Ninguna de estas decisiones es gratuita, y vale la pena registrar lo que se pierde:

  • Sin salto a una página arbitraria — un cursor no sabe responder "llévame a la página 37". Para un inbox, navegado como un flujo cronológico con búsqueda y filtros encima, nunca hizo falta; para una tabla administrativa, sería un problema real.
  • La virtualización agrega complejidad genuina — medición de alturas dinámicas, restauración de scroll, overscan calibrado. Es costo de ingeniería que solo se paga cuando la lista es realmente grande.
  • Lo que no está en el DOM no existe para el navegador — el Cmd+F de la página no encuentra filas no renderizadas, y los lectores de pantalla necesitan atención extra (aria-setsize/aria-posinset para los conteos). La búsqueda del producto pasa a ser la búsqueda de verdad.

Cuándo no usar nada de esto

Si tu lista tiene algunos cientos de ítems, quédate en el payload shaping y una paginación simple — la virtualización es una respuesta para miles de filas, no un patrón por defecto. La regla que adopté: paginación cursor-based cuando la lista crece sin techo junto con el uso del producto; virtualización cuando una sola lista amenaza con superar ~1.000 nodos en el DOM.

Qué merece atención en una segunda pasada

Dos puntos que merecen cuidado en cualquier implementación de esta arquitectura: restauración de scroll entre navegaciones — volver de una conversación a la lista debe dejar al usuario exactamente donde estaba, lo que exige persistir el offset fuera del componente, ya que el virtualizador no sobrevive al unmount — e invalidación quirúrgica en tiempo real: los mensajes nuevos que llegan por WebSocket deben actualizar la página correcta del cache con setQueryData, no invalidar la query entera y rehacer el trabajo que la paginación acababa de ahorrar.

La lección que queda es menos sobre bibliotecas y más sobre separar costos: red, serialización y renderizado se degradan por mecanismos diferentes y se resuelven con herramientas diferentes. La paginación cursor-based se ocupa de lo que viaja; la virtualización, de lo que se renderiza. Juntas, transformaron la pantalla más pesada del producto en la más estable — y su costo dejó de acompañar el crecimiento de los clientes.