Un panell d'administració comença sempre igual: una taula, quatre files de prova i un select sense where. Funciona. I com que funciona, ningú no el torna a mirar. Mentrestant la taula d'usuaris passa de quatre files a quaranta mil, i la pàgina interna que ningú no mesura es converteix en la més lenta del producte.
He hagut d'arreglar això en quatre projectes diferents, i sempre pels mateixos tres motius.
L'admin s'escriu sense cura perquè ningú no el veu
La pàgina pública té paginació des del primer dia. Algú la revisa, algú la mesura, hi ha una mètrica de Core Web Vitals que crida si triga. L'admin no té res d'això. L'usen dues persones, i aquestes dues persones assumeixen que va lent perquè "és l'admin".
En una API de transcripció que mantinc, les accions de servidor getAdminUsers i getAdminFeedback rebien limit com a paràmetre i el passaven directament a la consulta. Sense sostre. Ningú no ho havia tocat en mesos perquè des de la UI sempre arribava un 10. El problema no és la UI: és que una server action és un endpoint. Qualsevol amb sessió d'admin, o qualsevol script, podia demanar limit=1000000 i materialitzar la taula sencera a la memòria del worker.
La correcció va ser de tres línies, en una tanda de seguretat:
export const ADMIN_LIST_MAX_LIMIT = 1000;
const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);
El Math.max(limit, 1) no és paranoia gratuïta: un limit negatiu o zero també fa coses estranyes segons el driver. L'important és on viu el sostre. No al component, no al hook: a la capa que rep la petició. Tot el que hi ha per sobre és suggeriment.
El count que sembla gratis i no ho és
La paginació clàssica necessita saber quantes pàgines hi ha, i per a això cal un SELECT COUNT(*). L'escrius sense pensar perquè al cap d'un un count és barat. No ho és: a Postgres un count exacte recorre tota la taula o tot l'índex, i no hi ha manera que no ho faci.
En un gestor documental intern l'admin d'usuaris feia tres coses en paral·lel a cada càrrega de pàgina: portar la pàgina de perfils, portar totes les companyies per al selector, i comptar el total de perfils. Amb un pageSize de 5. És a dir, es recorria la taula sencera per pintar "pàgina 3 de 47".
Vaig treure el count i vaig canviar el contracte de la paginació:
setHasMore(profilesData.length === pageSize);
Es perd el "de 47". Es guanya que la consulta sigui sempre proporcional a la mida de pàgina. El botó de següent ara depèn de hasMore en comptes de currentPage < totalPages, i el rang que es mostra es calcula amb el que ja tens a la mà, sense preguntar res més:
const endIndex = startIndex + companies.length - 1;
Ningú no es va queixar de no veure el total. És una dada que en un panell intern no decideix absolutament res. Si de debò el necessites, reltuples de pg_class et dona una estimació a cost zero; per a "hi ha uns 40k usuaris" en sobra.
Portar metadades en comptes de l'objecte sencer
El tercer patró és més subtil. Una llista de fitxers carrega bé fins que els fitxers tenen contingut. En una eina de notes col·laboratives la barra lateral cridava getProjectTextFiles, que feia un select sense projecció: és a dir, portava també la columna content de cada fitxer del projecte. Per pintar una llista de noms.
La solució és avorrida i funciona: una segona funció que selecciona explícitament les columnes que la llista necessita.
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 contingut es demana en seleccionar un fitxer, no abans. I aquí apareix el detall que només es veu quan ho has fet: si l'usuari clica ràpid en dos fitxers, la resposta lenta del primer pot trepitjar el segon. Cal comprovar, abans d'escriure a l'store, que el fitxer continua sent el seleccionat:
if (fullFile && useAppStore.getState().selectedFileId === fileId) {
useAppStore.getState().setSelectedFile(fullFile);
}
Sense aquesta comprovació canvies un problema de rendiment per un bug de curses, que és pitjor perquè no és reproduïble a voluntat.
El bug que només apareix al panell d'admin
Un dels millors exemples de per què l'admin mereix atenció me'l va donar un dashboard d'analítica que vaig cachejar amb unstable_cache. Funcionava en local i a la primera visita de producció. Després queia amb Invalid time value.
unstable_cache serialitza el seu payload a JSON. Un Date escrit en l'execució freda torna com a string en totes les lectures cachejades. I Intl.DateTimeFormat.format no accepta un string. El tipus mentia: lastIngestedAt: Date | null era en realitat Date | string | null. TypeScript no ho podia veure perquè la frontera de serialització no està tipada.
Ho vaig arreglar normalitzant les dues formes i tractant allò impronunciable com a absent, no com a excepció:
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);
Una dada de data que no es pot pintar s'ha de mostrar com a "sense dades", no tombar la pàgina sencera. En un dashboard, una mètrica trencada no es pot endur per davant les altres dotze.
El que aplico avui a qualsevol llistat intern
- Sostre a la capa de servidor. Un
limitque arriba de fora s'acota on entra, amb una constant amb nom. La UI no és una validació. - Sense
countper defecte. Paginació perhasMore. Si algú demana el total, se li dona una estimació, i només si el demana. - Projecció explícita. Una llista selecciona columnes, mai
*. El blob es demana en obrir el detall. - Índex a la columna per la qual filtres. En una app de fitxatges hi havia índexs compostos
(user_id, date), però les consultes que filtraven només peruser_idno els aprofitaven bé; va caldre afegir l'índex simple.
El panell d'admin és el lloc on l'equip mira quan alguna cosa va malament a producció. Que sigui justament la part del sistema que no aguanta el volum de producció és una ironia cara.
