Performance Audit V14e EU_strict (2026-05-21)
VERDICT: OPTIMIZATIONS_NEEDED
Several pages serve every request cold (x-vercel-cache: MISS) due to a gap in
the proxy CDN-cache coverage list. Two pages show intermittent 500s under load.
The 360 KB triple-chunk and 166 KB globals.css are avoidable overhead. The
how-it-works page calls computePerformanceData() without any cache wrapper,
risking a 30 s Neon timeout on each render.
Lab metrics (TTFB curl, cold vs warm)
All measurements from eu-central-1 (Vercel CDN / Neon pooler same region). PageSpeed API key not available; Lighthouse scores not included (field data gap). "cold" = first curl hit; "warm" = subsequent hit after CDN may have populated.
| Page | Cold TTFB | Warm TTFB | Total (warm) | HTML size | HTTP | CDN hit |
|---|---|---|---|---|---|---|
| /fr/ | 0.64 s | 0.36-0.92 s | 1.0 s | 407 KB | 200 (intermittent 500) | MISS |
| /fr/recommendations/ | 0.59 s | 0.29 s | 12.0 s | 78 KB | 200 | MISS |
| /fr/performance/ | 1.64 s | 0.45 s | 0.71 s | 377 KB | 200 | MISS |
| /fr/methodologie/ | 0.44 s | 0.32 s | 12.0 s | 52 KB | 200 | MISS |
| /fr/companies/ | 0.43 s | 0.11-1.17 s | 5.5 s | 637 KB | 200 (intermittent 500) | HIT (300 s) |
| /fr/insiders/ | 0.48 s | 1.30 s | 8.5 s | 866 KB | 200 | HIT (300 s) |
| /fr/blog/ | 0.84 s | 0.36 s | 0.55 s | 132 KB | 200 | MISS |
| /fr/blog/xnas-article/ | 0.71 s | 0.35 s | 0.81 s | 102 KB | 200 (intermittent 500) | MISS |
| /fr/company/heritage-mining-ltd/ | 0.50 s | 0.12 s | 0.16 s | 130 KB | 200 | HIT (600 s) |
| /fr/leaderboard/insiders/ | 0.82 s | 0.13 s | 0.18 s | 222 KB | 200 | HIT (600 s) |
| /fr/about/ | 0.54 s | 0.28 s | 0.37 s | 71 KB | 200 | MISS |
| /fr/how-it-works/ | 0.43 s | N/A | TIMEOUT (30 s) | 59 KB | 200* | MISS |
*how-it-works eventually returns 200 but exceeded curl max-time 30 on first cold hit.
TTFB raw curl (cold + warm)
| Page | Cold | Warm | Delta | Cached? |
|---|---|---|---|---|
| /fr/ | 0.64 s | 0.36 s | -44% | No - force-dynamic |
| /fr/recommendations/ | 0.59 s | 0.29 s | -51% | No - force-dynamic |
| /fr/performance/ | 1.64 s | 0.45 s | -73% | No - force-dynamic |
| /fr/methodologie/ | 0.44 s | 0.32 s | -27% | No - force-dynamic |
| /fr/companies/ | 0.43 s | 0.11 s | -74% | YES - CDN 300 s |
| /fr/insiders/ | 0.48 s | 1.30 s | +171% | YES - CDN 300 s (stale-while-revalidate) |
| /fr/blog/ | 0.84 s | 0.36 s | -57% | No - missing in proxy coverage |
| /fr/company/:slug/ | 0.50 s | 0.12 s | -76% | YES - CDN 600 s |
| /fr/leaderboard/ | 0.82 s | 0.13 s | -84% | YES - CDN 600 s |
| /fr/about/ | 0.54 s | 0.28 s | -48% | No - missing in proxy coverage |
| /fr/how-it-works/ | 0.43 s | TIMEOUT | N/A | No - missing coverage + uncached DB query |
Top opportunities (ordered by impact)
1. Extend proxy.ts CDN cache coverage to missing /fr/* routes (savings: 200-400 ms TTFB per request, complexity: low)
applyCdnCache() in src/proxy.ts only covers /company/, /insider/,
/companies/, /insiders/, /leaderboard/, /hubs/, /markets/,
/sectors/. The following routes are absent and therefore never get
CDN-Cache-Control: public, s-maxage=N despite Next.js having appropriate
revalidate or unstable_cache wrappers on their DB calls:
/blog/and/blog/:slug/(revalidate=1800 set at page level but never reaches CDN)/about/(revalidate=86400, fully static content)/methodologie/(force-dynamic, but unstable_cache reuses DB result)/performance/(force-dynamic, unstable_cache revalidate=3600)/recommendations/(force-dynamic, unstable_cache revalidate=300)/how-it-works/(revalidate=3600 at page level, completely ignored)
Fix: add the missing stripped.startsWith(...) branches to applyCdnCache()
with appropriate sMaxAge values. Recommendations and performance are
user-agnostic for the public tab so a 300 s CDN cache is safe. Blog and
content pages can use 1800 s. About and how-it-works can use 3600 s.
Note: the locale-pref cookie bypass (if (req.cookies.get(LOCALE_PREF_COOKIE))
is missing from applyCdnCache(). If a user has this cookie the CDN cache is
bypassed entirely for that session, which is correct behavior to avoid FR/EN
cache cross-contamination.
2. Wrap computePerformanceData() in unstable_cache in how-it-works/page.tsx (savings: prevents 30 s timeout, complexity: low)
how-it-works/page.tsx calls computePerformanceData() directly at render
time with no cache layer. computePerformanceData() runs 5 concurrent Prisma
queries against backtestResult and declaration tables. On a cold Neon
connection from a new serverless instance this takes 25-30 s, hitting the
curl max-time and risking a 500 under Vercel's function timeout.
The page already has export const revalidate = 3600 but headers() at
line 41 forces the route into dynamic mode, making revalidate a no-op.
The DB query must be wrapped:
const loadLiveCagrsCached = unstable_cache(
loadLiveCagrs,
["how-it-works-cagrs"],
{ revalidate: 3600, tags: ["performance-data"] }
);
Then call await loadLiveCagrsCached() instead of await loadLiveCagrs().
3. Add transactionNature partial index for BUY/SELL patterns (savings: 30-50% on recommendation engine candidate query, complexity: medium)
The recommendation engine getBuyRecommendations() runs an ILIKE pattern
match on transactionNature (5 patterns: acqui%, souscription%, exercice%,
purchase%, %acquisition%). With 360k+ rows this is a full-scan fallback even
with the composite index on [pdfParsed, signalScore, pubDate]. Postgres
cannot use a B-tree for ILIKE. A partial GIN/trigram index or a materialized
boolean column (isAcquisition Boolean) would let the planner skip most rows
before the score filter.
Migration path: add isAcquisition Boolean @default(false) to Declaration,
backfill via UPDATE ... SET "isAcquisition" = true WHERE ..., add
@@index([isAcquisition, pdfParsed, signalScoreV13, pubDate]).
4. Reduce triple-duplicate 360 KB Turbopack chunk (savings: ~720 KB avoided transfer per page, complexity: low-medium)
Three chunks (0_g1b1x7rhqo~.js, 0tumc6jhylw18.js, 0zhh_~2yp97h3.js)
each 369,242 bytes are present with different hashes but identical file sizes.
Turbopack at the time of build (turbopack: {}) is not performing deduplication
across route groups. These are likely the same vendor library (recharts +
framer-motion + gsap bundle) being emitted three times for different entry
points. After a clean production build (next build) with --turbo disabled
or with explicit splitChunks config this triples to ~1.1 MB served per route
that pulls all three. Verify after next build if the duplication persists.
5. Neon connection_limit: reduce from 3 to 1 for serverless (savings: fewer connection pool exhaustion 500s, complexity: low)
src/lib/prisma.ts appends connection_limit=3 to the already-pooled Neon
URL. Neon's pgbouncer pooler manages the actual Postgres connections;
connection_limit=1 in the client is the recommended value for Vercel
serverless functions. At connection_limit=3, a burst of concurrent renders
(e.g. a CDN cache miss storm on /fr/companies/) opens up to 3x the function
count in simultaneous Prisma connections, which is the likely cause of the
intermittent 500s observed on /fr/, /fr/companies/, and /fr/blog/xnas-article/
during the audit.
Fix: change connection_limit=3 to connection_limit=1 in src/lib/prisma.ts.
6. Insiders page: 866 KB HTML payload (savings: 50-70% payload reduction, complexity: medium)
/fr/insiders/ returns 866 KB of HTML (PAGE_SIZE=150 rows). This is the
largest payload in the audit. Each row includes a rich InsiderRow structure.
Consider:
- Reducing PAGE_SIZE from 150 to 50
- Server-rendering only above-the-fold rows and deferring the rest via Suspense/pagination triggered on scroll
- The page already has ISR (revalidate=3600) and CDN cache (s-maxage=300), so this is a client-side byte-count issue, not a TTFB issue
7. methodologie/page.tsx: remove force-dynamic (savings: enables CDN caching, complexity: low)
/methodologie/ is force-dynamic because it calls prisma.declaration.count()
for the live sample-size in generateMetadata(). The count changes only when
new data is ingested (multiple times per day at most). Wrapping the count in
unstable_cache with revalidate: 3600 would allow removing force-dynamic
and replacing it with export const revalidate = 3600, making the page
ISR-eligible and CDN-cacheable.
8. globals.css: 166 KB single chunk (savings: 20-40 KB per page, complexity: medium)
src/app/globals.css is 5014 lines / 166 KB. The compiled CSS chunk
0bvqevet1mrcq.css is 141 KB (minified). This is a single global stylesheet
loaded on every route. Heavy animation keyframes, chart-specific overrides, and
page-specific utility classes should move to co-located CSS Modules per
component/route segment to enable CSS splitting. This would reduce the
per-page CSS budget to only what the route actually uses.
9. Blog article: add unstable_cache around autolink queries (savings: ~200 ms per render, complexity: low)
blog/[slug]/page.tsx calls prisma.company.findMany() and
prisma.insider.findMany() inside the render without any cache wrapper.
These are bounded by crossLinks.companies.length so are small queries, but
they run on every ISR re-render. Wrapping in unstable_cache with the article
slug as key and revalidate: 3600 would absorb repeated renders.
10. Add loading.tsx to blog, methodologie, about, how-it-works (savings: perceived LCP improvement, complexity: low)
None of these routes have a loading.tsx file. Without it, the browser waits
for the full SSR response before painting anything. Adding skeleton loaders
enables RSC streaming: the shell renders immediately (TTFB), content streams in
as DB queries resolve.
Database query hotspots
| Query | Location | Index used | Issue |
|---|---|---|---|
declaration.count({ where: { type: "DIRIGEANTS" } }) |
methodologie/page.tsx generateMetadata | @@index([type]) | Runs uncached on every request (force-dynamic) |
computePerformanceData() (5 Prisma queries) |
how-it-works/page.tsx | Various | No unstable_cache wrapper, 25-30 s cold on Neon |
$queryRaw ILIKE on transactionNature (5 patterns) |
recommendation-engine.ts getBuyRecommendations | None (ILIKE not B-tree) | Full scan fallback on 360k+ rows per reco request |
backtestResult.findMany + count |
performance-data.ts | @@index([snapshotDate]) | Covered by unstable_cache revalidate=3600 - OK |
$queryRawUnsafe insiders aggregate |
insiders/_shared.tsx | @@index([name]) partial | Raw SQL with window function, covered by unstable_cache=3600 - OK |
declaration.findMany getBuyRecommendations candidate fetch |
recommendation-engine.ts | @@index([pdfParsed, signalScore, pubDate]) | Covered by unstable_cache revalidate=300 - OK |
No N+1 patterns found. All list queries use explicit LIMIT/OFFSET or take.
Caching coverage
| Route | Page revalidate | unstable_cache | CDN (proxy.ts) | Net cache status |
|---|---|---|---|---|
| /fr/ | 60 s | Yes (5 caches) | Missing | MISS every request |
| /fr/recommendations/ | force-dynamic | Yes (300 s) | Missing | MISS every request |
| /fr/performance/ | force-dynamic | Yes (3600 s) | Missing | MISS every request |
| /fr/methodologie/ | force-dynamic | No | Missing | MISS + cold DB every request |
| /fr/companies/ | 600 s | Yes | Yes (300 s) | HIT - covered |
| /fr/insiders/ | 3600 s | Yes | Yes (300 s) | HIT - covered |
| /fr/blog/ | 1800 s | No | Missing | MISS every request |
| /fr/blog/[slug]/ | 3600 s | Partial (autolink uncached) | Missing | MISS every request |
| /fr/company/:slug/ | 3600 s | Yes | Yes (600 s) | HIT - covered |
| /fr/leaderboard/ | force-dynamic | Yes (3600 s) | Yes (600 s) | HIT - covered |
| /fr/about/ | 86400 s | No | Missing | MISS every request |
| /fr/how-it-works/ | 3600 s (overridden by headers()) | No | Missing | MISS + 30 s cold DB |
Summary: 6 of 12 audited routes have effective CDN coverage. The remaining 6 are fully uncached at the edge.
Bundle size
| Chunk | Size (raw) | Notes |
|---|---|---|
| 0_g1b1x7rhqo~.js | 361 KB | Likely vendor (recharts/framer/gsap) - TRIPLICATED |
| 0tumc6jhylw18.js | 361 KB | Same size as above |
| 0zhh_~2yp97h3.js | 361 KB | Same size as above |
| 0aqpm5mabssqz.js | 222 KB | Large vendor chunk |
| 0bvqevet1mrcq.css | 141 KB | Compiled globals.css |
| 0ux43.~1i5go6.js | 146 KB | |
| 03jatzi2t67zt.js | 135 KB | |
| 0nbxis6pc3dq6.js | 132 KB | |
| 0duf8h-zzsijl.js | 125 KB | |
| 0jqcu_tvk573l.js | 115 KB |
Total static/chunks directory: 3.7 MB (73 files). CSS total: 141 KB + 17 KB + 13 KB + 5 KB = 176 KB across 4 CSS chunks.
The triple-duplicate 360 KB chunk is the most actionable concern. If Turbopack
deduplication is the cause, a webpack build for production (removing
turbopack: {} from next.config.ts temporarily) will reveal whether the
duplication is a Turbopack artifact or a genuine code-splitting boundary issue.
optimizePackageImports is configured for lucide-react, recharts, framer-motion, date-fns, gsap, react-katex, marked - correct approach.
Image optimization
No raw <img> tags found in src/app or src/components outside of comments.
All images use next/image or CSS backgrounds.
Image config in next.config.ts:
- formats: ["image/avif", "image/webp"] - correct
- minimumCacheTTL: 86400 - correct
- remotePatterns: explicit whitelist (clearbit, vercel blob, duckduckgo, yahoo) - correct
No image optimization issues found.
Cold start risk
| Function / Route | maxDuration | Memory | Cold start risk | Notes |
|---|---|---|---|---|
| /fr/how-it-works/ | default (10 s Vercel hobby / 15 s pro) | default | CRITICAL | computePerformanceData() uncached = 25-30 s |
| /api/cron/route.ts | 300 s | 1024 MB | Low (cron only) | |
| /api/backtest/compute | 300 s | 1024 MB | Low (cron only) | |
| /fr/recommendations/ | default | default | Medium | force-dynamic + unstable_cache 300 s helps |
| /fr/performance/ | default | default | Low | unstable_cache 3600 s reduces DB pressure |
Page-level routes do not have maxDuration overrides in vercel.json - they use
the plan default. If the plan is Pro (15 s), how-it-works will 504 on cold
starts with an uncached computePerformanceData() call. Cron heartbeat on the
top 3 cold-start-prone routes (/fr/, /fr/recommendations/, /fr/how-it-works/)
every 5 minutes would keep functions warm on the Vercel Pro plan.
Action items by impact
[CRITICAL] Wrap computePerformanceData() in unstable_cache in how-it-works/page.tsx -- prevents 504/timeout; file:
src/app/how-it-works/page.tsx[HIGH] Extend applyCdnCache() in proxy.ts to cover /blog/, /about/, /methodologie/, /performance/, /recommendations/, /how-it-works/ -- fixes CDN MISS on 6 high-traffic routes; file:
src/proxy.tslines 289-318[HIGH] Reduce connection_limit from 3 to 1 in prisma.ts -- eliminates Neon pooler saturation causing intermittent 500s; file:
src/lib/prisma.tsline 21[MEDIUM] Remove force-dynamic from methodologie/page.tsx + wrap declaration.count in unstable_cache -- allows ISR + CDN caching on a fully static page; file:
src/app/methodologie/page.tsx[MEDIUM] Investigate triple-duplicate 360 KB Turbopack chunk -- potential 720 KB wasted transfer per affected route; action: run
next build(webpack mode) and compare chunk output[MEDIUM] Add loading.tsx to /blog/, /methodologie/, /about/, /how-it-works/ -- enables RSC shell streaming so TTFB paints immediately
[MEDIUM] Add isAcquisition boolean column + composite index to Declaration -- removes ILIKE full scan in recommendation engine; migration required
[LOW] Reduce insiders PAGE_SIZE from 150 to 50 -- cuts 866 KB HTML payload by ~65%; file:
src/lib/seo/pagination.ts[LOW] Move page-specific CSS to CSS Modules -- reduces per-route CSS load from 141 KB to only what the route needs
[LOW] Add cron heartbeat pings for /fr/, /fr/recommendations/, /fr/how-it-works/ -- prevents cold-start penalty on first visitor after idle period
Audit scope: V14e EU_strict. Data collected 2026-05-21 from eu-central-1. All figures from live curl measurements against insiders-trades.com (HEAD dpl_52RZJQwBjnCKhH5s6Vx3pC5xBEyJ). Lighthouse lab scores not available (no PSI API key in .env.local).