@satyam-mishra-pce

bumicerts-monorepo · marketplace-data-loads

Bumicerts marketplace data loading audit

41 autoresearch runs across 7 benchmark segments diagnosed why /explore and /organizations felt slow — hero image bloat and N-serial GraphQL calls were the dominant culprits, now fixed.

Date: 2026-05-13 · partial · Commit: 95b88da


Headline result

99.7%
Hero byte reduction
~285 ms
Warm data-visible (combined)
3.1 ms
Warm TTFB (explore + orgs)
41 runs
Autoresearch iterations

Summary. Warm server TTFB was never the bottleneck — both routes return in under 2 ms each when the Next.js data cache is warm. The real user-visible slowness had two roots: (1) enormous uncompressed PNG hero images (7.6 MB total) that stall LCP, and (2) the organizations route issuing one GraphQL request per DID before hydrating organization cards. Both are now fixed: heroes are 900 px AVIF with only light-theme variants prioritised (21.5 KB total, 16.4 KB preload-critical), org accounts are fetched in a single batched query, the explore listing uses a card-only data adapter skipping unused fields, and both pages use 5-minute unstable_cache revalidation instead of force-dynamic. Strict Playwright data-card selectors confirm warm combined data-visible at ~285 ms. Cold production latency and client JS bundle remain the next open work.


Objective & scope

The user reported that /explore and /organizations felt slow for real data to appear after the loading skeleton. The autoresearch benchmark campaign set out to:

All 41 runs targeted commit 95b88da on branch autoresearch/slow-explore-organizations-2026-05-13.


Methodology

Seven benchmark segments were run in sequence, each with a dedicated primary metric:

SegmentMetricRunsGoal
0route_ms (warm TTFB)1–2Establish baseline; confirm warm server is not the problem
1hero_kb (total hero bytes)3–8Compress hero images PNG→WebP→AVIF
2preload_kb (crash)9Parser bug; segment aborted
3preload_kb (priority hero bytes)10–18Reduce priority-preloaded hero bytes
4browser_ms (full load event)19–26Browser-measured load; found org GraphQL batching win
5route_ms (server data path)27–31Reduce SSR data-adaptation cost
6data_visible_ms (generic selectors)32–36First selector-based data-visible metric
7data_visible_ms (strict card selectors)37–41Strict [data-testid] selectors; final optimisation target

Each iteration ran on a warm dev server (Next.js on port 3001). The autoresearch.sh script collected warm TTFB via curl (median of 3 iterations). The strict browser benchmark (bench-browser-loads.mjs) drove Chromium via Playwright, waiting for [data-testid="explore-bumicert-card"] and [data-testid="organization-card"] to appear in the DOM before recording timings.


Kept fixes

Hero images: PNG → AVIF 900 px/q32 (runs 3–13)

Fixed. Both explore and organizations hero backgrounds were large PNGs (~7.6 MB total). They were converted to WebP, then AVIF, progressively compressed to 900 px wide at quality 32. Total hero payload fell from 7,632 KB → 21.5 KB (99.7% reduction). Because the images are decorative backgrounds placed behind strong gradient overlays, the compression is visually acceptable; mandatory visual QA at retina/wide is still recommended.

Before7,632 KB — 4 × PNG hero files After21.5 KB — 4 × AVIF (explore-light 12 KB, explore-dark 2.6 KB, org-light 4.8 KB, org-dark 2.6 KB) Filespublic/images/explore/*.avif, public/assets/organizations/*.avif

Priority preload: light-theme only (run 11)

Fixed. Both light and dark hero variants were marked priority in Next.js <Image>, causing the browser to eagerly preload hidden variants. Dark images are now className="hidden dark:block" without priority so they lazy-load when the dark theme is active. Priority-preloaded bytes fell from 45 KB → 16.4 KB (−64%). A future theme-aware <picture>/media preload would do better still.

5-minute unstable_cache revalidation (run 2)

Fixed. Both pages previously used export const dynamic = "force-dynamic" and cache: "no-store" for the labeler fetch, defeating Next.js data cache reuse. Both now wrap their data calls with unstable_cache(..., { revalidate: 300 }) and export revalidate = 300. Warm TTFB dropped from ~14 ms to <4 ms.

Batched organization GraphQL (run 20)

Fixed. requestOrganizationAccountsByDidBatch previously looped over each DID and called requestOrganizationAccounts individually inside the 25-DID chunk loop, issuing N serial indexer requests. It was changed to pass all DIDs in one call with where did in [...]. Under the browser load metric, combined load time improved from 540 ms → 478 ms. Code lives in lib/account/list.ts:558.

Skip org long-description normalization for listing (run 27)

Fixed. listOrganizationData built full organization data including longDescription normalization for every org, even though listing cards only show displayName, shortDescription, media, country, and bumicertCount. The builder now accepts { includeLongDescription: false } for the listing path (lib/account/list.ts:897). Under the route_ms metric this dropped combined route time from ~14 ms to ~3 ms on the warm server.

Card-only explore adapter (runs 40–41)

Fixed. The explore listing adapter (activitiesToBumicertDataArray) normalised full Leaflet document descriptions, short-description facets, contributor lists, and locationRefs for every activity, even though explore grid cards never render those fields. Passing { cardOnly: true } skips that work (lib/adapters.ts:302). Under strict card-selector data-visible timing, combined data_visible_ms improved 339 ms → 267 ms in autoresearch (validated twice). Detail and account pages still receive the full data shape.

Strict card-selector browser benchmark (run 37)

Fixed. Earlier browser metrics used generic a[href^='/account/'] selectors which could match navigation chrome, not actual data cards. Run 37 switched to [data-testid="explore-bumicert-card"] and [data-testid="organization-card"]. This did not change app behaviour but made the benchmark correctly measure real data visibility rather than incidental DOM content.


Discarded hypotheses

Skip per-activity funding-config GraphQL (runs 22, 33)

Explore listing cards never read fundingConfig, but normalizeActivities fetches ActivityFundingConfig for each listed activity. Skipping those calls regressed primary metrics in both the browser_ms and data_visible_ms segments. Warm caching/concurrency likely hides the cost in a local dev environment. Worth retrying with a cold or production instrumented trace.

Increase org account batch size to 100 (run 23)

After batching org DIDs into a single query (run 20), raising the chunk size from 25 to 100 was expected to reduce serial pagination. Primary browser_ms regressed. Either the current dataset is small enough that batch size does not matter, or the larger payload increases indexer response time. Do not increase batch size without direct DID-count instrumentation.

Org labeler fetch: no-storenext revalidate (run 24)

The external organization labeler fetch inside requestRecentOrganizations used cache: "no-store". Switching to next: { revalidate: 300 } was plausible but regressed browser_ms. The outer unstable_cache wrapper may already absorb the labeler latency; the inner fetch option may be irrelevant in the warm local path.

Org activity-count page size 500 → 1000 (run 25)

Counting Bumicerts per organization pages through orgHypercertsClaimActivity. Raising the page size from 500 to 1000 was expected to reduce pagination round trips, but primary metric regressed. Instrument actual page count and indexer request durations before tuning page size again.

Lightweight org listing GraphQL projection (run 29)

A separate GraphQL document omitting longDescription for the listing query was implemented to reduce raw payload bytes from the indexer. Added code complexity and a new uncached query path; primary route_ms regressed. Prefer payload reduction at the indexer API layer rather than duplicating query logic.

Disable Next Link prefetching for card links (run 38)

Explore and organizations grids render many <Link> components that auto-prefetch in viewport. Disabling prefetch improved the post-load event browser time but worsened strict data-visible time, confirming prefetch contention is not a bottleneck for data becoming initially visible.

Force-static both listing routes (run 36)

Explicitly marking both pages force-static regressed data-visible time badly (339 ms → 689 ms). The routes appear to need streaming/dynamic behavior in the dev path; force-static interacts poorly with current data fetch and streaming configuration.

Precompute country lookup map (run 28)

Organization adaptation scans all countries for each org to find a location code. Pre-building the map was a correct micro-optimisation but did not move route_ms; country lookup is not a meaningful bottleneck compared with data fetch work.

Direct Bun instrumentation script (run 35 — crash)

An attempt to time server page functions directly via a Bun script crashed because the script could not resolve Next.js server-only imports outside the Next runtime. Instrument via the Next.js environment or tsx, not bare Bun execution.


Current metrics (verified 2026-05-13)

autoresearch.sh warm TTFBexplore=1.7 ms, orgs=1.4 ms → route_ms=3.1 ms Hero payload (total)21.5 KB (was 7,632 KB PNG — 99.7% reduction) Priority-preloaded bytes16.4 KB (was 45 KB — only light-theme variants prioritised) data_visible_ms (bench run 1, cold cache)explore=430.5 ms, orgs=156.3 ms → 586.9 ms data_visible_ms (bench run 2, warm cache)explore=152.8 ms, orgs=132.2 ms → 285.1 ms data_visible_ms (autoresearch best, run 41)explore=141.6 ms, orgs=126.0 ms → 267.7 ms browser_ms (warm, load event)~513 ms combined (explore+orgs)

Cold-cache vs warm-cache gap. The first benchmark run of a fresh server session produced data_visible_ms=586.9 ms vs 285.1 ms on the second (warmed) run. This gap is the primary remaining user-visible issue: the Next.js data cache is cold on first requests and must hit remote indexer/labeler endpoints. Production cold-start is likely worse still. The warm figures above represent what repeat visitors experience after the first SSR warms the cache.


Next recommendations

Cold-start instrumentation

The warm dev server doesn't reproduce the slow user experience. Add server-side timing logs around listHighQualityBumicerts and listHighQualityOrganizations (the unstable_cache-wrapped functions) to measure actual cold indexer/labeler request durations in production. This should reveal whether the 5-minute cache TTL is hitting often enough or whether cache warming on startup would help.

Indexer-side listing projection

The org listing still fetches longDescription from the indexer even though normalization is now skipped. A dedicated lightweight listing GraphQL operation (omitting longDescription, fundingConfig) would reduce network payload from the indexer to the Next.js server. Measure indexer response bytes before and after to confirm the value.

Theme-aware image preloads

Currently both light and dark hero variants are in the DOM, but only one is prioritised. A <picture> element with media="(prefers-color-scheme: dark)" would let the browser preload only the active variant, halving priority preload bytes (16.4 KB → ~8 KB) without any dark-mode LCP regression.

Skip funding-config fetches — cold instrumentation only

The hypothesis that per-activity fundingConfig GraphQL is redundant for explore listing is structurally sound and worth validating in production. Run with explicit indexer call counting rather than relying on warm benchmark timing where concurrent caching obscures serial request costs.

Visual QA for AVIF hero quality

Hero images are now 900 px wide at quality 32, which is near the lower acceptable bound for desktop hero backgrounds. Perform visual review on large retina displays in both light and dark mode before shipping to production. If banding/softness is visible, try quality 38–42 (prior kept iterations were 1100 px/q38 and 1200 px/q42).


Reproducing

Dev serverbun --cwd apps/bumicerts dev --port 3001 Warm TTFB benchmarkPORT=3001 BENCH_ITERATIONS=3 bash autoresearch.sh Strict browser benchmarkPORT=3001 BENCH_ITERATIONS=3 node apps/bumicerts/scripts/bench-browser-loads.mjs Branchautoresearch/slow-explore-organizations-2026-05-13 Commit95b88da — Add autoresearch benchmark for slow marketplace routes Raw dataautoresearch.jsonl at repo root — 41 entries

Benchmark run log (run 2, warm cache)

TRACE iteration=1 route=/explore data_visible_ms=152.5 browser_ms=240.7
TRACE iteration=1 route=/organizations data_visible_ms=132.2 browser_ms=241.7
TRACE iteration=2 route=/explore data_visible_ms=175.1 browser_ms=271.8
TRACE iteration=2 route=/organizations data_visible_ms=174.9 browser_ms=254.6
TRACE iteration=3 route=/explore data_visible_ms=152.8 browser_ms=347.1
TRACE iteration=3 route=/organizations data_visible_ms=121.5 browser_ms=182.4
METRIC data_visible_explore_ms=152.8
METRIC data_visible_organizations_ms=132.2
METRIC data_visible_ms=285.1
METRIC browser_explore_ms=271.8
METRIC browser_organizations_ms=241.7
METRIC browser_ms=513.5

autoresearch.sh (warm TTFB, 2026-05-13)

TRACE using_existing_dev_server=1 port=3001
TRACE iteration=1 explore_ms=1.9 organizations_ms=3.6
TRACE iteration=2 explore_ms=1.7 organizations_ms=1.4
TRACE iteration=3 explore_ms=1.5 organizations_ms=1.4
METRIC preload_kb=16.4
METRIC hero_kb=21.5
METRIC explore_ms=1.7
METRIC organizations_ms=1.4
METRIC route_ms=3.1

All 41 autoresearch runs (summary)

#StatusMetricDescription
1keeproute_ms=14Baseline warm TTFB — not the bottleneck
2keeproute_ms=13.85-min unstable_cache + remove force-dynamic/no-store
3keephero_kb=7632.6Baseline hero PNG payload
4keephero_kb=304.7PNG → WebP (−96%)
5keephero_kb=184.8WebP → AVIF (−39%)
6keephero_kb=103.4AVIF 1280 px/q48 (−44%)
7keephero_kb=65.4AVIF 1200 px/q42 (−37%)
8keephero_kb=45AVIF 1100 px/q38 (−31%)
9crashpreload_kb=0preload_kb parser bug — segment aborted
10keeppreload_kb=45Baseline priority preload (all 4 variants)
11keeppreload_kb=34.9Remove priority from dark hero variants (−22%)
12keeppreload_kb=22.6AVIF 1000 px/q34 (−35%)
13keeppreload_kb=16.4AVIF 900 px/q32 (−27%) ← current kept
14discardpreload_kb=11.6800 px/q30 — too quality-risky
15discardpreload_kb=11.6Remove orgs light priority — harms orgs LCP
16discardpreload_kb=13.9850 px/q31 explore only — marginal gain, quality risk
17–18discardpreload_kb=16.4Control reruns — no code change
19keepbrowser_ms=540.3Browser baseline (load event via Playwright)
20keepbrowser_ms=477.8Batch all org DIDs in one GraphQL call
21discardbrowser_ms=656.5Label cache reuse — warm benchmark regressed
22discardbrowser_ms=581.9Skip funding-config — warm benchmark regressed
23discardbrowser_ms=635Batch size 100 — regressed
24discardbrowser_ms=669.7Org labeler cache — regressed
25discardbrowser_ms=603.9Activity page size 1000 — regressed
26discardbrowser_ms=664.7Skip org long-desc (under browser_ms) — regressed
27keeproute_ms=3.3Skip org long-desc normalization (under route_ms) ← kept
28discardroute_ms=14.2Country lookup precompute — not a bottleneck
29discardroute_ms=13.8Lightweight org listing GraphQL — regressed
30discardroute_ms=13.4Card-only explore adapter (under route_ms) — regressed
31discardroute_ms=10.1Control rerun — noise estimate
32keepdata_visible_ms=323.3Selector-based data-visible baseline (generic selectors)
33discarddata_visible_ms=378.1Skip funding-config — regressed under data_visible
34discarddata_visible_ms=380.8Control rerun — noise estimate
35crashdata_visible_ms=0Bun instrumentation script — module resolution crash
36discarddata_visible_ms=689force-static — badly regressed
37keepdata_visible_ms=339.1Strict card-selector benchmark ← measurement fix
38discarddata_visible_ms=378.2Disable Link prefetch — data-visible worsened
39discarddata_visible_ms=345.3Remove cache-busting query param — no gain
40keepdata_visible_ms=315.8Card-only explore adapter (under strict selector)
41keepdata_visible_ms=267.7Validation rerun — improvement confirmed