Skip to content

perf: cache SSR data fetching to eliminate upstream pool fanout - #31

Merged
erubboli merged 1 commit into
masterfrom
perf/ssr-pool-fanout
Sep 14, 2026
Merged

erubboli merged 1 commit into
masterfrom
perf/ssr-pool-fanout

Conversation

@erubboli

Copy link
Copy Markdown
Member

Problem

Homepage and /pools SSR took 12-18s TTFB. Each render issued ~177 upstream calls (measured via a request-counting proxy in front of the testnet api-web-server):

  • fetchAllPoolsFromApi paged /pool?offset=N 10 items at a time → 34 sequential calls per crawl
  • one /pool/{id}/delegations call per pool (155 pools) per crawl
  • /pools crawled twice per render (summary + list each fetched independently)
  • 10 sequential /block/{id} fetches for the recent-blocks widget
  • in testnet mode (no DATABASE_URL) none of this was cached — every render paid the full cost

Fix

  • src/lib/server-cache.ts (new): in-process TTL cache with in-flight de-duplication and stale-while-revalidate, persisted on globalThis so page bundles and route-handler bundles in the same server process share one store (same pattern as the existing pg pool singleton in lib/postgres.ts).
  • src/lib/explorer-ssr.ts: single shared getPoolsSnapshot() (120s SWR) now feeds the homepage summary, /pools, /api/pool/list and /api/pool/summary; computed pool summary cached 30s, mempool 10s, transaction total 60s.
  • src/lib/explorer-source.ts:
    • pool crawl uses items=100 pages fetched in adaptive parallel rounds with retries (upstream breaks for items > 100; verified)
    • recent blocks fetched in parallel via /chain/{height} id lookups + Promise.all block fetches (sequential previous-block walk kept as fallback for older API versions)
    • chain/tip cached 5s, recent transactions 10s, immutable block/height lookups 1h (in-memory + Next force-cache data cache)
  • API routes /api/pool/list, /api/pool/summary, /api/block/tip: route through the shared cached loaders; response shapes unchanged. /api/block/tip stays fast under k8s probe hammering (in-flight dedup, no locks).

Compatibility

  • Mainnet mode unchanged: DB-first logic preserved — with a cron-populated Postgres, verified 0 upstream pool calls end-to-end (worker sync + server against a scratch Postgres).
  • Cache is per-pod in-memory — safe for multi-pod deployments; every pod independently converges within the TTL.
  • No API response shapes changed.

Measurements (testnet API via local counting proxy)

before after
Homepage cold / warm TTFB 17.1s / 15.8s 4.5s / 0.015s
Upstream pool calls per render 34 + 155 delegations 0 warm; one snapshot build (3 + 155) per 120s TTL per pod
10 concurrent cold renders ~1,770 upstream calls ~161 calls, one shared build, all renders ~4.5s
/api/block/tip under 20-way hammering 1 upstream call per hit 5-46ms, 0 upstream within 5s TTL
Mainnet (DB populated) DB-first 0 pool calls (verified)

Cold is bounded by one shared crawl (~4.5s through a latency-adding local proxy; lower against production). next build, tsc --noEmit and 14/14 jest tests pass (4 new tests cover the cache: dedup, TTL, SWR, error propagation).

@erubboli
erubboli merged commit 7678382 into master Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants