02Performance5 min

Adminlijsten zijn het eerste wat omvalt

Het interne paneel wordt zonder limieten geschreven omdat er in het begin vier rijen zijn. Precies daar knapt alles als het product groeit.

Door Salva Castells

Omslagafbeelding: Marijovanni, CC BY-SA 4.0.
In dit bericht

Een adminpaneel begint altijd hetzelfde: een tabel, vier testrijen en een select zonder where. Het werkt. En omdat het werkt, kijkt niemand er ooit nog naar. Ondertussen groeit de gebruikerstabel van vier rijen naar veertigduizend, en wordt de interne pagina die niemand meet de traagste van het product.

Ik heb dit in vier verschillende projecten moeten repareren, en altijd om dezelfde drie redenen.

De admin wordt slordig geschreven omdat niemand hem ziet

De publieke pagina heeft paginering vanaf dag één. Iemand reviewt hem, iemand meet hem, er is een Core Web Vitals-metriek die gilt als het te lang duurt. De admin heeft niets daarvan. Twee mensen gebruiken hem, en die twee gaan ervan uit dat het traag is omdat "het de admin is".

In een transcriptie-API die ik onderhoud kregen de server actions getAdminUsers en getAdminFeedback limit als parameter en gaven die rechtstreeks door aan de query. Zonder plafond. Niemand had het in maanden aangeraakt omdat er vanuit de UI altijd een 10 binnenkwam. Het probleem is niet de UI: het is dat een server action een endpoint is. Iedereen met een adminsessie, of elk script, kon limit=1000000 vragen en de hele tabel in het geheugen van de worker materialiseren.

De correctie was drie regels, in een beveiligingsronde:

export const ADMIN_LIST_MAX_LIMIT = 1000;

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

De Math.max(limit, 1) is geen gratis paranoia: een negatieve of nul-limit doet ook rare dingen, afhankelijk van de driver. Wat telt is waar het plafond woont. Niet in de component, niet in de hook: in de laag die het verzoek ontvangt. Alles daarboven is een suggestie.

De count die gratis lijkt en dat niet is

Klassieke paginering moet weten hoeveel pagina's er zijn, en daarvoor heb je een SELECT COUNT(*) nodig. Je schrijft het zonder na te denken omdat een count in je hoofd goedkoop is. Dat is het niet: in Postgres doorloopt een exacte count de hele tabel of de hele index, en er is geen manier om dat te vermijden.

In een intern documentbeheersysteem deed de gebruikersadmin bij elke paginalading drie dingen parallel: de pagina profielen ophalen, alle bedrijven ophalen voor de selector, en het totaal aan profielen tellen. Met een pageSize van 5. Oftewel: de hele tabel werd doorlopen om "pagina 3 van 47" te tonen.

Ik haalde de count weg en veranderde het contract van de paginering:

setHasMore(profilesData.length === pageSize);

Je verliest de "van 47". Je wint dat de query altijd evenredig is aan de paginagrootte. De volgende-knop hangt nu af van hasMore in plaats van currentPage < totalPages, en het getoonde bereik wordt berekend met wat je al in handen hebt, zonder verder iets te vragen:

const endIndex = startIndex + companies.length - 1;

Niemand klaagde dat het totaal weg was. Het is een gegeven dat in een intern paneel helemaal niets beslist. Heb je het echt nodig, dan geeft reltuples uit pg_class je gratis een schatting; voor "er zijn zo'n 40k gebruikers" is dat genoeg.

Metadata ophalen in plaats van het hele object

Het derde patroon is subtieler. Een bestandenlijst laadt prima totdat de bestanden inhoud hebben. In een tool voor gezamenlijke notities riep de zijbalk getProjectTextFiles aan, die een select zonder projectie deed: hij haalde dus ook de kolom content van elk bestand in het project op. Om een lijst met namen te tonen.

De oplossing is saai en werkt: een tweede functie die expliciet de kolommen selecteert die de lijst nodig heeft.

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)));

De inhoud wordt opgevraagd bij het selecteren van een bestand, niet eerder. En daar duikt het detail op dat je pas ziet als je het gedaan hebt: klikt de gebruiker snel op twee bestanden, dan kan het trage antwoord van het eerste over het tweede heen gaan. Je moet vóór het schrijven in de store controleren of het bestand nog steeds het geselecteerde is:

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

Zonder die controle ruil je een performanceprobleem in voor een race-bug, en dat is erger omdat die niet naar believen reproduceerbaar is.

De bug die alleen in het adminpaneel verschijnt

Een van de beste voorbeelden van waarom de admin aandacht verdient kreeg ik van een analytics-dashboard dat ik cachede met unstable_cache. Het werkte lokaal en bij het eerste bezoek in productie. Daarna viel het om met Invalid time value.

unstable_cache serialiseert zijn payload naar JSON. Een Date die in de koude run wordt geschreven komt terug als string in alle gecachede leesbewerkingen. En Intl.DateTimeFormat.format accepteert geen string. Het type loog: lastIngestedAt: Date | null was in werkelijkheid Date | string | null. TypeScript kon het niet zien omdat de serialisatiegrens niet getypeerd is.

Ik loste het op door beide vormen te normaliseren en het onuitspreekbare als afwezig te behandelen, niet als uitzondering:

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

Een datumwaarde die niet getoond kan worden hoort te verschijnen als "geen gegevens", niet de hele pagina om te trekken. In een dashboard mag één kapotte metriek de andere twaalf niet meesleuren.

Wat ik vandaag op elke interne lijst toepas

  • Plafond in de serverlaag. Een limit die van buiten komt wordt begrensd waar hij binnenkomt, met een benoemde constante. De UI is geen validatie.
  • Standaard geen count. Paginering via hasMore. Vraagt iemand om het totaal, dan krijgt hij een schatting, en alleen als hij erom vraagt.
  • Expliciete projectie. Een lijst selecteert kolommen, nooit *. De blob wordt opgevraagd bij het openen van het detail.
  • Index op de kolom waarop je filtert. In een tijdregistratie-app waren er samengestelde indexen (user_id, date), maar de queries die alleen op user_id filterden benutten die niet goed; er moest een enkelvoudige index bij.

Het adminpaneel is waar het team kijkt als er iets misgaat in productie. Dat juist dat deel van het systeem het productievolume niet aankan, is een dure ironie.