wandres.dev
LISTAS GRANDES · Virtualización y paginación

Alternativas a la virtualización: casi siempre hay una mejor

Agrupar filas con content-visibility, paginar por cursor en vez de por desplazamiento, mover el filtrado a un worker, y la tabla de decisión completa.

⏱ 19 min

Virtualizar es la respuesta correcta a un conjunto de problemas más pequeño de lo que la gente cree. Antes está el agrupamiento con content-visibility, que da la mayor parte del beneficio sin romper nada; está la paginación por cursor, que resuelve el problema en el servidor; y está la pregunta incómoda de si alguien va a mirar de verdad esas diez mil filas. Esta lección recorre las alternativas en el orden en que hay que evaluarlas y termina con la tabla de decisión.

🎯 Al terminar esta lección sabrás
  • Aplicar content-visibility por grupos de filas y explicar por qué por grupos y no por fila.
  • Distinguir la paginación por desplazamiento de la paginación por cursor y elegir la correcta.
  • Mover el filtrado y la ordenación de conjuntos grandes a un worker con transferencia sin copia.
  • Rellenar la tabla de decisión para tu caso concreto.

content-visibility por grupos: la opción que casi nadie prueba

Aplicar content-visibility: auto a cada fila de una tabla de cinco mil filas es exactamente lo que el nivel anterior desaconseja: cinco mil elementos con seguimiento de relevancia y contención permanente, cada uno para ahorrarse el layout de un puñado de nodos. La contabilidad se come el beneficio.

La forma correcta es agrupar. Envuelve cada cincuenta o cien filas en un contenedor y aplica la propiedad al contenedor. Pasas de cinco mil elementos vigilados a cien, cada uno protegiendo cincuenta veces más trabajo. El equilibrio se invierte por completo.

<div class="tabla" role="grid" aria-rowcount="5000">
  <div class="grupo" role="rowgroup"><!-- filas 1-50 --></div>
  <div class="grupo" role="rowgroup"><!-- filas 51-100 --></div>
  <!-- ... -->
</div>
.grupo {
  content-visibility: auto;
  /* 50 filas de 44px. Con auto, tras el primer render se usa el real. */
  contain-intrinsic-block-size: auto 2200px;
}

/* Los primeros grupos, los del primer viewport, sin saltar nada:
   no queremos meter contencion en el camino del LCP. */
.grupo:nth-child(-n + 2) {
  content-visibility: visible;
}

Lo que ganas frente a una lista virtual es todo lo que la lista virtual rompe: la búsqueda del navegador funciona, las anclas funcionan, el lector de pantalla recorre el conjunto entero, la impresión sale completa, la selección de texto atraviesa la tabla, el botón de atrás restaura el scroll. Lo que pierdes es el ahorro de memoria: los cinco mil elementos siguen existiendo en el DOM y ocupando, aunque no se maqueten ni se pinten.

Ese es exactamente el criterio para elegir entre las dos. Si tu problema es el tiempo de renderizado y la latencia de las actualizaciones, el agrupamiento con content-visibility lo resuelve. Si tu problema es la memoria —listas de decenas de miles de elementos en móviles de gama baja—, no lo resuelve y hay que virtualizar. Mide antes de decidir; la mayoría de la gente asume que su problema es memoria cuando es renderizado.

Paginación por desplazamiento y por cursor

Si los datos vienen de un servidor, la mejor optimización de la lista es no traerla entera. Pero hay dos formas de paginar y solo una escala.

Por desplazamiento es la que todo el mundo escribe primero:

SELECT id, nombre, creado_en
FROM pedidos
ORDER BY creado_en DESC
LIMIT 50 OFFSET 100000;

Tiene dos defectos graves. El primero es de rendimiento: la base de datos tiene que producir y descartar cien mil filas para devolverte cincuenta, así que el coste de la página N crece con N. El segundo es de corrección: si alguien inserta un pedido mientras el usuario pasa de la página 3 a la 4, el desplazamiento se descuadra y el usuario ve un elemento repetido o se salta uno. En una lista con inserciones frecuentes, esto no es un caso raro: es lo normal.

Por cursor —también llamada por conjunto de claves— resuelve los dos:

-- El cursor es el par (creado_en, id) del ultimo elemento entregado.
SELECT id, nombre, creado_en
FROM pedidos
WHERE (creado_en, id) < ($1, $2)
ORDER BY creado_en DESC, id DESC
LIMIT 50;

La comparación de tuplas usa el índice compuesto directamente, así que el coste de cualquier página es el mismo, y como el cursor identifica una posición en los datos y no un número de fila, las inserciones no lo descuadran. El precio es que no puedes saltar a la página cuarenta y siete, solo avanzar y retroceder. Para un feed es irrelevante; para una tabla con paginador numerado, no sirve.

La regla práctica: cursor para todo lo que se recorre secuencialmente, desplazamiento solo cuando el usuario necesita saltar a una página concreta y el conjunto es pequeño. Y si necesitas las dos cosas, la solución habitual es limitar el desplazamiento a las primeras N páginas y obligar a filtrar más allá, que es lo que hacen todos los buscadores grandes.

Reducir el problema en vez de renderizarlo

Tres técnicas que atacan la causa y no el síntoma.

Filtrar y ordenar en un worker. Si de verdad necesitas los cien mil registros en el cliente, tenerlos no es el problema: el problema es procesarlos en el hilo principal cada vez que el usuario escribe una letra. Un worker con los datos y un canal de índices resuelve esto sin complicar la vista.

// worker-datos.js
let filas = [];

self.onmessage = ({ data }) => {
  if (data.tipo === 'cargar') {
    filas = data.filas;
    self.postMessage({ tipo: 'listo', total: filas.length });
    return;
  }

  if (data.tipo === 'filtrar') {
    const consulta = data.consulta.toLowerCase();
    const encontrados = [];
    for (let i = 0; i < filas.length; i++) {
      if (filas[i].nombre.toLowerCase().includes(consulta)) encontrados.push(i);
    }
    // Enviamos indices en un array tipado y transferimos su buffer:
    // cero copia, sea cual sea el tamano.
    const indices = Int32Array.from(encontrados);
    self.postMessage({ tipo: 'resultado', indices }, [indices.buffer]);
  }
};
// hilo principal
const worker = new Worker(new URL('./worker-datos.js', import.meta.url), {
  type: 'module',
});

worker.onmessage = ({ data }) => {
  if (data.tipo === 'resultado') {
    lista.cambiarTotal(data.indices.length);
    indicesVisibles = data.indices;
  }
};

entrada.addEventListener('input', () => {
  worker.postMessage({ tipo: 'filtrar', consulta: entrada.value });
});

La transferencia del ArrayBuffer en el segundo argumento de postMessage es lo que hace que esto escale: sin ella, cada resultado se serializa y se copia, y con cien mil índices la copia vuelve a bloquear el hilo que querías liberar.

Agrupar y plegar. Cinco mil filas de registros de servidor son inmanejables; cinco mil filas agrupadas por hora en veinticuatro grupos plegados son perfectamente manejables, y además son más útiles, porque el usuario ve la forma de los datos antes de bajar al detalle. Un elemento details por grupo da el plegado nativo, accesible y sin JavaScript, y el contenido plegado no se maqueta.

Resumir. La pregunta que hay que hacer antes de cualquier decisión técnica: ¿qué va a hacer el usuario con las diez mil filas? Si la respuesta es “buscar una”, lo que necesita es un buscador. Si es “ver la tendencia”, lo que necesita es un gráfico. Si es “analizarlo en su herramienta”, lo que necesita es un botón de exportar. En los tres casos, renderizar diez mil filas es resolver mal un problema que estaba mal planteado.

Cuando alguien pide diez mil filas, casi nunca quiere diez mil filas

Este es el patrón que más veces he visto y el que más tiempo de ingeniería ha consumido inútilmente. Llega el requisito: “la tabla tiene que mostrar todos los pedidos, sin paginar, porque los operadores necesitan verlo todo”. Se acepta como dado, se construye una lista virtual con alturas variables, se pelea con la accesibilidad, con la impresión y con el botón de atrás, y se entrega tres semanas después. Y entonces observas a un operador usándola: escribe algo en el buscador, mira seis filas y hace clic en una. Nunca ha desplazado más de dos pantallas. Lo que quería no era ver diez mil filas —nadie puede ver diez mil filas, el ojo humano no hace eso— sino no tener que adivinar en qué página está lo que busca, que es un problema completamente distinto y que se resuelve con un buscador decente y un orden estable. La pregunta que hay que hacer, y hay que hacerla antes de escribir una línea, no es “¿cuántas filas?” sino “enséñame qué haces con la tabla”. Las respuestas reales son casi siempre cuatro: buscar un elemento concreto, comparar unos pocos, detectar anomalías, o exportar para trabajar fuera. Buscar se resuelve con un campo de texto. Comparar, con selección múltiple y una vista de comparación. Detectar anomalías, con orden por la columna relevante y resaltado, o con un gráfico. Exportar, con un botón que genera un fichero, que es además la única forma honesta de dar “todos los datos” a alguien que de verdad los quiere todos. Ninguna de las cuatro necesita diez mil nodos en el DOM. Y esto no es una excusa para no aprender a virtualizar —hay casos legítimos, sobre todo en herramientas profesionales de datos donde el desplazamiento continuo es parte del flujo de trabajo— sino un orden de evaluación. La virtualización es la respuesta correcta cuando has descartado las cuatro anteriores, y solo entonces. Si la eliges antes de haber mirado cómo se usa la pantalla, estás asumiendo semanas de deuda técnica para resolver un requisito que nadie ha comprobado.

La tabla de decisión

Situación Solución Por qué
Menos de 500 filas simples Renderizar todo El coste no justifica nada más
Hasta ~2000 filas, problema de actualización contain: content por fila + delegación Corta la propagación sin romper nada
Página larga con secciones, no una lista content-visibility: auto por sección Ganancia grande, coste nulo
Tabla grande, problema de renderizado Agrupar y content-visibility por grupo Conserva búsqueda, anclas y accesibilidad
Datos en servidor, recorrido secuencial Paginación por cursor Coste constante y estable ante inserciones
Datos en servidor, salto a página N Desplazamiento limitado + filtros El salto exige desplazamiento; limítalo
Decenas de miles, problema de memoria Virtualización Es lo único que reduce el número de nodos
Filtrado o ordenación pesados en cliente Worker con transferencia Saca el trabajo del hilo principal
El usuario busca un elemento Buscador Es lo que quería
El usuario quiere todos los datos Exportar a fichero Es lo que quería

Un apunte final sobre combinar. Estas opciones no son excluyentes y las buenas soluciones suelen ser dos o tres juntas: paginación por cursor para traer, agrupamiento con content-visibility para renderizar, worker para filtrar, y un buscador encima. La virtualización completa, con todo su coste de accesibilidad y de navegación, se reserva para cuando las demás no llegan. Cómo verificar que llegaron o no, con medidas, está en el coste de diez mil nodos, y lo que asumes si acabas virtualizando, en la virtualización por ventana.

⚔️ Evalúa antes de virtualizar
  1. Observa a un usuario real usando tu tabla grande durante diez minutos. Anota qué hace. Compara con lo que asumías.
  2. Agrupa tus filas de cincuenta en cincuenta con content-visibility y mide el tiempo de renderizado inicial y la latencia de una pulsación de tecla. Compara con la lista virtual.
  3. Convierte una consulta paginada por desplazamiento a paginación por cursor y mide el tiempo de la página 1 y de la página 200 en ambas.
  4. Mueve el filtro de tu lista a un worker con transferencia de índices y mide el bloqueo del hilo principal al escribir.
  5. Rellena la tabla de decisión con los datos de tu aplicación y justifica la fila que aplica.