हमने Business Members page को ~100x तेज कैसे बनाया

Business Members page हर load पर लगभग 1.8 seconds server time ले रहा था। हमने इसे दो compounding design flaws तक trace किया, दोनों fix किए, और वही page अब cache से single-digit milliseconds में render होता है।

Performance improvement

  • पहले: ~1.8 seconds
  • बाद में: <10 milliseconds
  • Improvement: ~100x+

PayCal Lens से measured.

Business administrators के लिए Members page अब effectively instant लगता है, larger organizations में भी।

Executive summary

Page affected Business Members — हर business member को computed financial columns के साथ दिखाने वाली grid
पहले ~1.8 seconds server time per page load
बाद में Cache hits पर single-digit milliseconds (~100x+ improvement); cache misses भी पुराने path से तेज
Root causes Redis के against N+1 query pattern और हर view पर एक साल की payroll math की full recomputation
Fix Batched/pipelined Redis lookups, plus finished grid data का materialized cache with explicit invalidation
Data freshness Member-related change पर cache तुरंत invalidated; 5-minute expiry safety net की तरह staleness bound करती है

हम इसे क्यों publish कर रहे हैं

Performance transparency का हिस्सा है। जब कोई page slow होता है, users को यह जानना चाहिए कि slowness काम की प्रकृति से आती है या avoidable design flaw से। इस case में यह दूसरा था, दो बार, और details share करने योग्य हैं क्योंकि दोनों flaws web software की common performance mistakes हैं।

इस article में कोई security issue या user data exposure शामिल नहीं है। यह सिर्फ एक engineering story है कि slow page को fast कैसे बनाया गया।

Page क्या करता है

Business Members page किसी business के हर member को list करता है। Name और role के साथ grid पांच computed financial columns दिखाता है:

ये values finished numbers के रूप में store नहीं होतीं। ये हर member की raw work entries से computed होती हैं, यानी Redis database में stored individual shift records से। एक row बनाने का मतलब profile load करना, full year work entries load करना, regular/overtime split करना और gross pay sum करना है।

  • Year-to-date gross — इस year की total earnings
  • Total hours — इस year की सभी worked hours
  • Regular hours — standard rate वाली hours
  • Overtime hours — regular threshold से आगे की hours
  • Trailing baseline — comparison के लिए rolling reference figure

Problem, part 1 — N+1 query pattern

Original implementation members list fetch करने के लिए एक query करती थी, और फिर लगभग 100 members में से हर member के लिए Redis तक separate, sequential round-trips करती थी: profile के लिए एक और full year work entries के लिए और calls.

कुछ members वाले small businesses ज्यादा प्रभावित नहीं थे; stacked latency तब दिखती है जब organizations dozens या hundreds of members तक बढ़ते हैं।

यह classic “N+1” pattern है: list के लिए one query, फिर items के लिए N more queries one at a time. Redis lookup fast है, पर हर round-trip fixed network latency pay करता है; sequential calls में ये costs linearly stack होते हैं।

Database bottleneck नहीं था। Database के साथ conversation bottleneck थी।

Problem, part 2 — हर view पर सब कुछ recompute करना

Second flaw ने first को compound किया। Payroll math, year of work entries को regular/overtime में split करना, year-to-date gross और trailing baseline calculate करना, हर member के लिए, हर page view पर scratch से दोहराया जा रहा था।

Work entries अक्सर नहीं बदलतीं। Members page की consecutive views के बीच underlying data लगभग हमेशा same होता है। फिर भी हर visit ने just-computed results को फिर से compute करने की full cost pay की।

दोनों flaws मिलकर लगभग 1.8 seconds server time per page load produce कर रहे थे, PayCal Lens से measured।

Fix, part 1 — Redis pipelining

Redis pipelining support करता है: commands का batch single round-trip में भेजना और answers साथ में receive करना। Server अब one question/wait pattern की जगह all questions at once पूछता है।

हमने batched lookup method Database::pipelineHgetall() add किया और members grid को इसे use करने में convert किया। Profile lookups one round-trip में और work-entry lookups दूसरे round-trip में gather होते हैं।

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

इस change ने अकेले hundreds of sequential latency payments को handful में collapse किया।

Fix, part 2 — Materialized cache

Pipelining computation को feed करना cheaper बनाता है; second fix computation repeat करना avoid करता है। हमने BusinessMembersCache introduce किया, server-side cache जो finished computed grid data Redis में store करता है।

Cache freshness को दो rules govern करते हैं:

  • 5-minute expiry. हर cached grid 5 minutes बाद automatically expire होती है।
  • Change पर explicit invalidation. Role update, member added, या member removed जैसे member-related changes cached grid तुरंत delete करते हैं।

Member identity और permission checks intentionally cache नहीं किए जाते। हर request full access-control path चलाती है; केवल expensive financial arithmetic reuse होती है।

Impact

  • Cache hits grid को single-digit milliseconds में serve करते हैं — previous ~1.8 seconds से ~100x+ improvement.
  • Cache misses भी पुराने page से तेज हैं, क्योंकि recomputation अब pipelined, batched lookups पर चलती है।
  • Correctness tested है। Contract और unit tests cache invalidation behavior cover करते हैं।
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.

हम ऐसे engineering write-ups आगे भी publish करेंगे पारदर्शिता हब.

Engineering facts

  • 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