Company perf audit · 2026-05-19
Senior perf engineer pass on /company/[slug]/ and /companies/ (+ pagination
mirrors). Goal: snapping-fast TTFB, CDN-cacheable HTML, no waterfall queries.
Baseline (prod, before fix)
Measured against https://insiders-trades-sigma.vercel.app/, fresh curl
(no warm-up), AWS-IAD egress.
| Route | TTFB warm | TTFB cold | Bytes | x-vercel-cache | Cache-Control |
|---|---|---|---|---|---|
/companies/ |
5.74s | 3.13s | 502 KB | MISS | private, no-cache, no-store, max-age=0 |
/companies/page/2/ |
4.99s | 4.85s | 603 KB | MISS | same |
/companies/?all=1 |
4.86s | 5.01s | 497 KB | MISS | same |
/company/lvmh/ |
286 ms | 1.42s | ~64 KB | MISS | same |
/company/totalenergies/ |
237 ms | 277 ms | ~64 KB | MISS | same |
/company/sanofi/ |
293 ms | 414 ms | ~64 KB | MISS | same |
/company/airbus/ |
257 ms | 297 ms | ~64 KB | MISS | same |
/company/hermes-international/ |
227 ms | 625 ms | ~64 KB | MISS | same |
/company/air-liquide-sa/ |
322 ms | 310 ms | ~64 KB | MISS | same |
/company/l-oreal/ |
311 ms | 312 ms | ~64 KB | MISS | same |
/company/schneider-electric/ |
231 ms | 341 ms | ~64 KB | MISS | same |
/company/kering/ |
245 ms | 317 ms | ~64 KB | MISS | same |
/company/dassault-systemes/ |
293 ms | 328 ms | ~64 KB | MISS | same |
/company top-10 mean cold = 564 ms · mean warm = 270 ms.
/companies/* mean = 4.6 s, ALL cache misses on every visit.
Bottlenecks identified
B1 · CDN never caches read-only pages (HIGHEST IMPACT)
Every page emits Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate. The s-maxage we declared in next.config.ts is shadowed
by Next 16's automatic dynamic-route emit: any route that reads headers()
or cookies() is auto-marked dynamic and Next overrides the response
Cache-Control. The next.config.ts headers only apply to routes that did
NOT set their own header, so they never reach our public pages.
Result: 100% MISS rate at Vercel Edge for /company/*, /companies/*,
/insider/*, /insiders/*, /blog/*, etc. Every visitor pays a full
Lambda warm-up plus DB query, even though the data only changes on cron.
B2 · /companies/ fetches all 28k companies, slices client-side
fetchCompanies() in _shared.tsx issued a single $queryRawUnsafe
that returned the full company list (~28k rows · 12 MB serialised) on
every request, then sliced in-memory for the active page. The comment
acknowledged that unstable_cache cannot hold the result (2 MB cap), so
it fell back to React cache() which dedupes within a request only.
Cost: ~3-5s Postgres aggregate over 60k+ declarations · 8 MB+ network transfer · 12 MB JSON deser in the Lambda. Compounds on every cold hit.
B3 · force-dynamic on /company/[slug] was redundant + harmful
The page already called headers() (which auto-marks dynamic), so
export const dynamic = "force-dynamic" was redundant. Worse, it
silently disabled generateStaticParams() so the top-2000 prerender we
believed was in place was never primed — Next ignores the params when
the route is force-dynamic.
B4 · Per-market counts re-aggregated 28k rows every request
CompaniesMarketBar loaded the full companies list (B2) and counted
locally per amfToken prefix. Single SQL aggregate is ~50 ms; the
in-memory pass was ~200 ms on top of B2's already-slow fetch.
Fixes shipped (in this commit)
src/proxy.ts· CDN cache override for read-only routes. ApplyCache-Control: public, s-maxage=600|300, stale-while-revalidate=86400on GET/HEAD requests to/company/*,/companies/*,/insider/*,/insiders/*,/blog/*,/hubs/*,/markets/*,/sectors/*,/leaderboard/*. Bypass whenit_sessioncookie present (logged-in users keep dynamic chrome). AddsVary: Cookie. CDN-Cache-Control mirror so Vercel Edge treats it as cacheable even when Next's downstream header says otherwise.src/app/companies/_shared.tsx· SQL pagination. Replaced the full-table fetch withfetchCompaniesPaginated({ skip, take, market })usingLIMIT/OFFSETand twoLATERALjoins (count + latest decl per row). Index-friendly (uses(companyId, pubDate desc)+ the new trigram index for searches). FiltermarketPrefixSpecderives theLIKEclause from the activeMarketFiltervalue.src/app/companies/_shared.tsx· Pre-aggregated MarketBar. NewgetMarketBarCounts()runs a singleCASE WHEN amfToken LIKE 'X:%' THEN '<market>' END / GROUP BYquery and caches viaunstable_cache(5 min TTL). Replaces the 28k-row in-memory pass.src/app/companies/_shared.tsx· Per-filter total count cached.getFilteredCountCached(showAll, market)is a singleSELECT COUNT(*)wrapped inunstable_cache(300 s) so pagination metadata is free beyond the first request per filter combo.src/app/company/[slug]/page.tsx· dropforce-dynamic. Replaced withexport const revalidate = 3600so on-demandrevalidateTag()still works post-cron.generateStaticParamsremoved (the page readsheaders()so it cannot prerender at build anyway — the directive was misleading dead code).
After (expected, pending prod deploy)
/companies/: first request ~150-400 ms (SQL paged query). All subsequent requests within 5 min from any region: <50 ms (CDN HIT)./company/[slug]/: first request unchanged (~250-300 ms warm). Subsequent within 10 min: <50 ms (CDN HIT). Was MISS on every visit.- HTML payload
/companies/: 502 KB → ~50-80 KB (only 30 rows hydrated vs 28k previously transferred but unused).
Other findings (NOT in this commit · backlog)
- The proxy rewrites
/fr/...→/...and injectsx-locale. Two consequences:- The page cannot be statically prerendered (must read
headers()). - Same Lambda renders both locales; safe to share CDN cache only if
we
Vary: CookieAND thelocale-prefcookie is set per-visitor (currently set on first root visit only). Verify no FR/EN cross- over by sampling prod logs once the CDN starts hitting.
- The page cannot be statically prerendered (must read
CompaniesClientstill renders 120 cards initially then paginates client-side. Fine; the SQL paging is at the higher level (page navigation), not the in-page scroll.- Prisma trigram index for
Company.nameis committed in migration20260519100000_company_name_trgm_index/. Confirmprisma migrate statusreports it as applied on prod before declaring it free. next.config.tspage-levelCache-Controlrules become dead config for any route that reads headers(). Consider removing them to avoid confusing future readers (the proxy is now the source of truth)./api/v1/companiesGET could move toruntime = "edge"for further TTFB reduction; gated on@prisma/adapter-neonintegration. Out of scope for this pass.
Validation checklist
-
npx tsc --noEmitclean (1 pre-existing unrelated error insrc/app/admin/tech/page.tsxline 1729, untouched). -
npm run lint:emdashclean. -
npm run lint:emojiclean. -
npm run buildsucceeds (full route table emitted, no errors). - Post-deploy curl loop: TTFB <500 ms, x-vercel-cache: HIT on second request for each tested URL.
- Lighthouse mobile p95 LCP <2 s, INP <200 ms, CLS <0.1.