02Rendimiento7 min

Nadie mide el panel de admin, y por eso es lo primero que revienta

La página interna que usan dos personas no tiene métricas, ni revisiones, ni límites. He tenido que arreglarla en cuatro proyectos, y casi siempre por las mismas razones.

Por Salva Castells

Imagen de portada: Marijovanni, CC BY-SA 4.0.
En este post

Tenía un dashboard de analítica interno que funcionaba perfecto en local y en la primera visita en producción. En la segunda se caía con Invalid time value. Recargabas y seguía caído. Lo había envuelto en unstable_cache de Next para no recalcular las métricas en cada visita, y ahí estaba el problema, aunque tardé un rato en creérmelo.

unstable_cache serializa lo que devuelves a JSON. En la ejecución en frío, lastIngestedAt es un Date y todo va bien. En cualquier lectura desde la caché, ese mismo campo vuelve como string, e Intl.DateTimeFormat.format no acepta un string. El tipo decía Date | null y en realidad era Date | string | null, y TypeScript no tenía manera de saberlo porque la frontera de serialización no está tipada. El compilador confiaba en mí y yo confiaba en el compilador.

El arreglo fue normalizar las dos formas y tratar lo que no se puede pintar como ausente, en lugar de dejar que lance:

const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);

Una fecha rota debería mostrarse como "sin datos", no llevarse por delante la página. En un dashboard con trece métricas, que una esté mal no puede tumbar las otras doce.

Cuento esto primero porque es el ejemplo más limpio de algo que me he encontrado una y otra vez: el panel de admin es la parte del producto que nadie mira hasta que se rompe. Ese fallo no lo habría pillado ningún test que yo tuviera, porque solo aparece cuando la caché ya está caliente, y en local casi nunca lo está.

Sin público no hay presión

La página pública tiene paginación desde el primer día. Alguien la revisa, alguien la mide, y si tarda te lo chivan las Core Web Vitals. El admin se escribe con cuatro filas de prueba y un select sin where, funciona, y ya nadie vuelve a tocarlo. Mientras, la tabla de usuarios crece hasta decenas de miles de filas, y la página que nadie mide se convierte en la más lenta de todo el sistema. La ironía es que es justo la página que abres cuando algo va mal en producción.

Me ha tocado arreglar esto en cuatro proyectos distintos. Las causas se repiten tanto que ya las busco directamente.

Una server action es un endpoint, aunque no lo parezca

En una API de transcripción que mantengo, las server actions getAdminUsers y getAdminFeedback recibían limit como parámetro y lo pasaban tal cual a la consulta. Sin techo. Nadie se había fijado en meses porque desde la UI siempre llegaba un 10.

Lo que tardé en interiorizar es que la UI no es la única que llama a esa función. Una server action es un endpoint HTTP con otra sintaxis, y cualquiera con sesión de admin (o un script con esa sesión) podía pedir limit=1000000 y cargar la tabla entera en la memoria del worker. El arreglo fue de tres líneas y entró en una tanda de cambios de seguridad:

export const ADMIN_LIST_MAX_LIMIT = 1000;

const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);

El Math.max(limit, 1) tiene su porqué: Postgres rechaza un LIMIT negativo con un error, y un LIMIT 0 te devuelve una lista vacía sin quejarse, y ninguna de las dos cosas es lo que quieres. Una cosa que no vi en su momento y que he visto al escribir esto: si limit llega como NaN (un Number("abc") de un query string, por ejemplo), Math.max(NaN, 1) es NaN y el techo no hace nada. Si el valor viene de texto, merece la pena un Number.isFinite antes.

Lo importante, en cualquier caso, es dónde vive el techo. Ni en el componente ni en el hook, sino en la capa que recibe la petición. Todo lo que hay por encima es una sugerencia.

El count que nadie echa de menos

La paginación de toda la vida quiere enseñar "página 3 de 47", y para eso necesita un SELECT COUNT(*). Lo escribes sin pensar porque en la cabeza un count suena barato. En Postgres un count exacto tiene que recorrer la tabla entera o un índice entero; no hay atajo.

En un gestor documental interno, el admin de usuarios hacía tres cosas en paralelo en cada carga: traer la página de perfiles, traer todas las compañías para un selector y contar el total de perfiles. Con un pageSize de 5. Se recorría la tabla completa para pintar cinco filas y un número que nadie miraba.

Quité el count y cambié cómo se decide si hay más páginas:

setHasMore(profilesData.length === pageSize);

El botón de siguiente pasó a depender de hasMore en vez de currentPage < totalPages, y el rango visible se calcula con lo que ya tienes en la mano:

const endIndex = startIndex + companies.length - 1;

(Tiene un caso feo, lo reconozco: si el total es múltiplo exacto del tamaño de página, la última página llena dice que hay más y la siguiente sale vacía. Se arregla pidiendo pageSize + 1 filas y enseñando solo pageSize. En un panel interno lo dejé estar.)

Nadie se quejó de perder el "de 47". En un panel interno es un dato que no decide nada. Si alguien necesita la cifra sí o sí, reltuples en pg_class da una estimación sin recorrer nada, y para "tenemos unos cuarenta mil usuarios" sobra.

La columna gorda que viaja sin que la pidas

Este es más sutil porque no se nota hasta que los datos tienen contenido. En una herramienta de notas colaborativas, la barra lateral llamaba a getProjectTextFiles, que hacía un select sin proyección. Traía todas las columnas, incluida content, de todos los ficheros del proyecto. Para pintar una lista de nombres.

La solución es aburrida: otra función que pide solo lo que la lista necesita.

return await db
  .select({
    id: textFiles.id,
    projectId: textFiles.projectId,
    name: textFiles.name,
    isTemp: textFiles.isTemp,
    deletedAt: textFiles.deletedAt,
    createdAt: textFiles.createdAt,
    updatedAt: textFiles.updatedAt,
  })
  .from(textFiles)
  .where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));

El contenido se pide al seleccionar un fichero. Y aquí sale algo que no tenías antes: si el usuario hace clic rápido en dos ficheros, la respuesta lenta del primero puede llegar después y pisar al segundo. Antes de escribir en el store hay que comprobar que ese fichero sigue siendo el seleccionado:

if (fullFile && useAppStore.getState().selectedFileId === fileId) {
  useAppStore.getState().setSelectedFile(fullFile);
}

Sin esto habría cambiado un problema de rendimiento por una condición de carrera, que es peor porque no la reproduces cuando quieres. Me gusta este ejemplo porque enseña que casi todas las optimizaciones de este tipo convierten una operación en dos, y en cuanto hay dos hay orden.

Un índice del que no estoy seguro

En una app de fichajes había índices compuestos (user_id, date), y aun así las consultas que filtraban solo por user_id iban peor de lo esperado. Acabé añadiendo un índice simple sobre user_id y mejoró.

Aquí tengo que ser honesto: en teoría un índice compuesto con user_id como primera columna ya sirve para filtrar solo por user_id, y Postgres lo sabe usar. Que el simple ayudara puede deberse a que es más pequeño, a estadísticas desactualizadas o a algo que no llegué a entender. Debería haber mirado el EXPLAIN ANALYZE con más calma antes de añadirlo. Lo dejo aquí porque es el tipo de cosa que en un admin nadie revisa después.

Lo que miro ahora en cualquier listado interno

No es gran cosa, y por eso no hay excusa:

  1. ¿El limit se acota en el servidor, con una constante con nombre?
  2. ¿Hay un count que se podría sustituir por hasMore?
  3. ¿La consulta de la lista selecciona columnas concretas o se trae todo?
  4. ¿Alguna fecha, o cualquier cosa que no sea JSON puro, pasa por una caché que serializa?
  5. ¿Hay un EXPLAIN de la consulta principal con datos de tamaño real, no con cuatro filas?

La quinta es la que casi nunca hago. Ahí sigue.