Summary
/analysis serialises a user's entire snapshot history — including the per-account breakdown of every snapshot ever taken — into the RSC payload and hands it to a client component, which then recomputes every chart in the browser on each range switch. Nothing is windowed by the selected range, so both the payload and the client-side work grow without bound as the account ages.
Evidence
src/lib/services/analysis-payload-service.ts:25-32 bundles four unbounded reads:
const [snapshots, cashFlowData, rawHistory, accountCashFlow, investmentCostBasis] =
await Promise.all([
getFullNormalizedHistory(userId, baseCurrency), // every snapshot, no date bound
getMonthlyCashFlow(userId, baseCurrency),
getRawHistoryWithBreakdown(userId, baseCurrency), // every snapshot + breakdown JSON
getAccountMonthlyCashFlow(userId, baseCurrency), // every (account, month) pair
getInvestmentCostBasisSummary(userId, baseCurrency),
]);
src/lib/services/history-service.ts:299-353 — getRawHistoryWithBreakdown selects the heavy breakdown column for all of the user's snapshots and expands it into one accountValues object per day:
prisma.netWorthSnapshot.findMany({
where: { userId }, // no date filter
select: { date: true, breakdown: true, baseCurrency: true, createdAt: true },
orderBy: { date: "asc" },
}),
src/app/(main)/analysis/page.tsx:40-49 passes all of it straight into a client component, and src/components/analysis/analysis-view.tsx:1 is "use client":
<AnalysisView
snapshots={snapshots}
cashFlowData={cashFlowData}
rawHistory={rawHistory}
accountCashFlow={accountCashFlow}
...
/>
So everything crosses the server/client boundary. analysis-view.tsx:22-31 then imports ten pure aggregation functions from analysis-service.ts (aggregateMonthlyChange, computeKpis, fillMonthRange, buildCashFlowBuckets, buildCumulativeGrowth, aggregateCategoryHistory, computePerformanceAttribution, computeInvestmentReturn, computeInvestmentReturnSeries, computeDrawdownSeries) and runs them client-side — which also pulls that module into the client bundle.
The contrast with the dashboard is instructive: dashboard-content.tsx:312-320 explicitly notes that getFullNormalizedHistory "never selects the heavy breakdown column". The analysis path does.
Failure scenario
A three-year-old account with 12 accounts:
- ~1 100 daily snapshots.
rawHistory.snapshots = 1 100 entries × 12 accountId → number pairs. With cuid keys (~25 chars) that is on the order of 400–500 KB of JSON before compression, for a view whose default range shows 12 months.
snapshots (NormalizedSnapshot[]) adds another 1 100 objects carrying id, date, createdAt, three numbers, baseCurrency, label, note.
accountCashFlow adds one entry per (account, month) with any activity — up to 12 × 36.
On a phone that is a multi-hundred-KB RSC payload to parse plus ten aggregation passes over ~1 100 records, repeated on every range toggle (YTD / 6M / 1Y / All) because the memo inputs are the full arrays. getCachedAnalysisPayload (revalidate: 300, tags net-worth / snapshots / exchange-rates / prices / history:${userId} / accounts:${userId}) means a cache miss also re-runs the full breakdown scan server-side — and those tags are invalidated by ordinary writes.
Suggested fix
The range is already a first-class concept in the UI (analysis-view.tsx ranges), so push it down instead of shipping everything:
- Bound the query. Give
getRawHistoryWithBreakdown and getFullNormalizedHistory a from date derived from the selected range (plus one prior month for the baseline that aggregateMonthlyChange needs), and make the range part of the getCachedAnalysisPayload cache key. getFullNormalizedHistory already supports options.from/to — getRawHistoryWithBreakdown does not.
- Aggregate on the server. The ten functions in
analysis-service.ts are pure and already take exactly the arrays they need; call them in the server component and send the derived series (a few dozen points each) rather than the raw history. That drops both the payload and the client bundle.
- If "All" must stay unbounded, at minimum drop
id / createdAt / label / note from the client-bound NormalizedSnapshot (only date and the three numbers are charted) and pre-collapse rawHistory to month-end snapshots — aggregateCategoryHistory and computeInvestmentReturnSeries only ever use month boundaries anyway.
Verification method
Static reading of analysis-payload-service.ts, history-service.ts, analysis/page.tsx and analysis-view.tsx. Payload sizes above are estimated from the data shapes, not measured against a real account — worth confirming with a production-sized history before sizing the fix.
Summary
/analysisserialises a user's entire snapshot history — including the per-accountbreakdownof every snapshot ever taken — into the RSC payload and hands it to a client component, which then recomputes every chart in the browser on each range switch. Nothing is windowed by the selected range, so both the payload and the client-side work grow without bound as the account ages.Evidence
src/lib/services/analysis-payload-service.ts:25-32bundles four unbounded reads:src/lib/services/history-service.ts:299-353—getRawHistoryWithBreakdownselects the heavybreakdowncolumn for all of the user's snapshots and expands it into oneaccountValuesobject per day:src/app/(main)/analysis/page.tsx:40-49passes all of it straight into a client component, andsrc/components/analysis/analysis-view.tsx:1is"use client":So everything crosses the server/client boundary.
analysis-view.tsx:22-31then imports ten pure aggregation functions fromanalysis-service.ts(aggregateMonthlyChange,computeKpis,fillMonthRange,buildCashFlowBuckets,buildCumulativeGrowth,aggregateCategoryHistory,computePerformanceAttribution,computeInvestmentReturn,computeInvestmentReturnSeries,computeDrawdownSeries) and runs them client-side — which also pulls that module into the client bundle.The contrast with the dashboard is instructive:
dashboard-content.tsx:312-320explicitly notes thatgetFullNormalizedHistory"never selects the heavybreakdowncolumn". The analysis path does.Failure scenario
A three-year-old account with 12 accounts:
rawHistory.snapshots= 1 100 entries × 12accountId → numberpairs. With cuid keys (~25 chars) that is on the order of 400–500 KB of JSON before compression, for a view whose default range shows 12 months.snapshots(NormalizedSnapshot[]) adds another 1 100 objects carryingid,date,createdAt, three numbers,baseCurrency,label,note.accountCashFlowadds one entry per (account, month) with any activity — up to 12 × 36.On a phone that is a multi-hundred-KB RSC payload to parse plus ten aggregation passes over ~1 100 records, repeated on every range toggle (
YTD / 6M / 1Y / All) because the memo inputs are the full arrays.getCachedAnalysisPayload(revalidate: 300, tagsnet-worth/snapshots/exchange-rates/prices/history:${userId}/accounts:${userId}) means a cache miss also re-runs the fullbreakdownscan server-side — and those tags are invalidated by ordinary writes.Suggested fix
The range is already a first-class concept in the UI (
analysis-view.tsxranges), so push it down instead of shipping everything:getRawHistoryWithBreakdownandgetFullNormalizedHistoryafromdate derived from the selected range (plus one prior month for the baseline thataggregateMonthlyChangeneeds), and make the range part of thegetCachedAnalysisPayloadcache key.getFullNormalizedHistoryalready supportsoptions.from/to—getRawHistoryWithBreakdowndoes not.analysis-service.tsare pure and already take exactly the arrays they need; call them in the server component and send the derived series (a few dozen points each) rather than the raw history. That drops both the payload and the client bundle.id/createdAt/label/notefrom the client-boundNormalizedSnapshot(onlydateand the three numbers are charted) and pre-collapserawHistoryto month-end snapshots —aggregateCategoryHistoryandcomputeInvestmentReturnSeriesonly ever use month boundaries anyway.Verification method
Static reading of
analysis-payload-service.ts,history-service.ts,analysis/page.tsxandanalysis-view.tsx. Payload sizes above are estimated from the data shapes, not measured against a real account — worth confirming with a production-sized history before sizing the fix.