Hoe we de Business Members-pagina ~100x sneller maakten

De Business Members-pagina kostte bij elke load ongeveer 1,8 seconden servertijd. We traceerden dit naar twee elkaar versterkende ontwerpfouten, losten beide op, en dezelfde pagina rendert nu vanuit cache in milliseconden met één cijfer.

Prestatieverbetering

  • Voor: ~1,8 seconden
  • Na: <10 milliseconden
  • Verbetering: ~100x+

Gemeten met PayCal Lens.

Voor bedrijfsbeheerders voelt de Members-pagina nu vrijwel direct, zelfs bij grotere organisaties.

Managementsamenvatting

Betrokken pagina Business Members — de grid met elk bedrijfslid en berekende financiële kolommen
Voor ~1,8 seconden servertijd per paginaload
Na Milliseconden met één cijfer bij cache hits (~100x+ verbetering); cache misses zijn ook sneller dan het oude pad
Oorzaken Een N+1-querypatroon tegen Redis en volledige herberekening van een jaar payroll-rekenwerk bij elke view
Fix Gebatchte/pipelined Redis-lookups plus een gematerialiseerde cache van de voltooide griddata met expliciete invalidatie
Datafreshness Cache wordt direct geïnvalideerd bij elke member-gerelateerde wijziging; een expiratie van 5 minuten begrenst staleness als safety net

Waarom we dit publiceren

Performance is onderdeel van transparantie. Als een pagina traag is, moeten gebruikers kunnen weten of dat inherent is aan het werk of komt door een vermijdbare ontwerpfout. Hier was het dat laatste, twee keer, en beide fouten zijn veelvoorkomende performanceproblemen in websoftware.

Dit artikel gaat niet over een beveiligingsprobleem of blootstelling van gebruikersdata. Het is puur een engineeringverhaal over een trage pagina snel maken.

Wat de pagina doet

De Business Members-pagina toont elk lid van een bedrijf. Naast naam en rol toont de grid vijf berekende financiële kolommen:

Deze waarden worden nergens als afgeronde cijfers opgeslagen. Ze worden berekend uit de ruwe work entries van elk lid, de shiftrecords in Redis. Eén rij maken betekent profiel laden, een volledig jaar entries laden, uren splitsen in regulier en overwerk, en brutoloon optellen.

  • Year-to-date gross — totale inkomsten tot nu toe dit jaar
  • Totaal uren — alle gewerkte uren dit jaar
  • Reguliere uren — uren tegen standaardtarief
  • Overuren — uren boven de reguliere drempel
  • Trailing baseline — rollende referentiewaarde voor vergelijking

Het probleem, deel 1 — Het N+1-querypatroon

De oorspronkelijke implementatie deed één query voor de ledenlijst en daarna, voor ongeveer 100 leden, losse sequentiële round-trips naar Redis: één voor het profiel en meer voor het volledige jaar work entries.

Kleine bedrijven merkten hier weinig van; de gestapelde latency wordt zichtbaar wanneer organisaties groeien naar tientallen of honderden leden.

Dit is het klassieke “N+1”-patroon: één query voor de lijst, daarna N extra queries één voor één. Redis-lookups zijn snel, maar elke round-trip betaalt vaste netwerklatency; sequentieel stapelt dat lineair.

De database was nooit de bottleneck. Het gesprek met de database was dat wel.

Het probleem, deel 2 — Alles bij elke view herberekenen

De tweede fout versterkte de eerste. Alle payroll-berekeningen, een jaar entries splitsen in regulier en overwerk, year-to-date gross berekenen en trailing baseline afleiden, werden voor elk lid bij elke page view opnieuw gedaan.

Work entries veranderen niet vaak. Tussen twee opeenvolgende views zijn de onderliggende data bijna altijd gelijk. Toch betaalde elk bezoek de volledige kost van net berekende en weggegooide resultaten.

Samen leverden de twee fouten ongeveer 1,8 seconden servertijd per paginaload op, gemeten met PayCal Lens.

De fix, deel 1 — Redis pipelining

Redis ondersteunt pipelining: een batch commando’s in één round-trip sturen en alle antwoorden samen ontvangen. De server stelt nu alle vragen tegelijk.

We voegden Database::pipelineHgetall() toe en zetten de members-grid om. Profielen gaan in één round-trip en work-entry lookups in een andere.

// Before - one round-trip per member, latency stacks linearly
foreach ($memberIds as $id) {
    $profiles[$id] = Database::hgetall($profileKey($id));
}

// After - one round-trip for the whole batch
$profiles = Database::pipelineHgetall(array_map($profileKey, $memberIds));

Deze wijziging bracht honderden sequentiële latencybetalingen terug tot enkele.

De fix, deel 2 — Een gematerialiseerde cache

Pipelining maakt de berekening goedkoper om te voeden; de tweede fix voorkomt herhaling. We introduceerden BusinessMembersCache, een server-side cache die de voltooide griddata in Redis bewaart.

Twee regels bepalen cachefreshness:

  • Expiratie van 5 minuten. Elke gecachte grid verloopt automatisch na 5 minuten.
  • Expliciete invalidatie bij wijziging. Elke member-gerelateerde wijziging verwijdert de gecachte grid direct.

Memberidentiteit en permissiechecks worden bewust niet gecachet. Elke request voert nog steeds het volledige access-controlpad uit.

Impact

  • Cache hits serveren de grid in milliseconden met één cijfer — een ~100x+ verbetering tegenover de eerdere ~1,8 seconden.
  • Cache misses zijn nog steeds sneller dan de oude pagina, omdat herberekening nu via gepipelinede batchlookups loopt.
  • Correctheid is getest. Contract- en unittests dekken cache-invalidatiegedrag.
Performance improvement summary showing ~1.8 seconds before, under 10 milliseconds after, and roughly 100x improvement measured with PayCal Lens
PayCal Lens measured server time before and after the fix.
PayCal Lens performance summary before optimization showing 1812 milliseconds total duration with financial summaries, profile hydration, and sequential work entry lookups as the slowest paths
Before: PayCal Lens ranked the per-member financial recomputation and sequential Redis round trips as the dominant costs (~1812 ms total).
PayCal Lens performance summary after optimization showing 7 milliseconds total duration with BusinessMembersCache get as the top path and cache hit status
After: the same page on a cache hit completes in single-digit milliseconds (~7 ms), with the materialized grid read as the primary work.
Business members grid table showing member names, roles, year-to-date gross, and total hours columns populated
The members grid loads immediately on repeat visits while access control still runs on every request.

What We Took Away From This

  • Measure before optimizing. PayCal Lens told us exactly where the 1.8 seconds went. Without per-request timing instrumentation, both flaws would have been guesses.
  • Latency stacks; batch it. Many small fast queries issued sequentially are slower than one large batched query. Round-trips, not data volume, dominated this page.
  • Cache finished work, invalidate eagerly. A cache is only trustworthy if every write path that affects it also clears it. The expiry is a safety net, not the mechanism.

We blijven engineeringverslagen zoals dit publiceren in de Transparantiehub.

Engineeringfeiten

  • Commit(s): 3db2229b, 2b3eafb8, f63773ea
  • Files Changed: 14 (under html/)
  • Tests Added: 12 (BusinessMembersCacheTest — 9 cases; LensRenderTest — 3 cases)
  • Tests Passing: 1901 (full PHPUnit suite, June 2026)
  • Performance Impact: ~1.8 s → ~7 ms (<10 ms on cache hits)
  • Production Status: Deployed