perf(dashboard): cut cold-load waterfall and widget fade-in#357
perf(dashboard): cut cold-load waterfall and widget fade-in#357hugodemenez wants to merge 2 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Remove the sequential layout → trades → userData → account-metrics waterfall after auth. Start Stripe concurrently without blocking isLoading, and compute account metrics client-side from already-loaded trades instead of a second trades DB round-trip. Co-authored-by: Hugo Demenez <hugodemenez@users.noreply.github.com>
Fetch getTradesAction cache pages with Promise.all instead of a sequential loop. Cut widget mount fade from 1.5s to 0.2s so ready data is not masked by animation. Co-authored-by: Hugo Demenez <hugodemenez@users.noreply.github.com>
3a99c18 to
e15f988
Compare
There was a problem hiding this comment.
Daily review — PR #357
Verdict: Approve (rebased onto beta at e15f988e; merge blocked by token permissions — draft PR also needs manual ready-for-review).
Solid perf PR that attacks the real cold-load bottlenecks: sequential loadData waterfall, duplicate metrics server round-trip, sequential trade pagination, and the 1.5s widget fade masking ready data.
Validation
- Rebased cleanly onto current
beta(resolved conflicts indata-provider.tsx+database.ts; fixedgetTradesAction(false)signature drift from beta auth hardening). bun run typecheckpasses.
Highlights
Promise.allfor layout/trades/userData after auth is the right critical-path cut.- Client
computeMetricsForAccountsmatchescalculateAccountMetricsAction(same shared util) without a second trades query. - Stripe decoupled from
isLoading— spinner should clear meaningfully earlier. refreshAllDataparallelizes trades/user refresh and recomputes metrics from final store state (good race fix).
Follow-ups (non-blocking)
- Consider closing superseded PR #352.
- Optional: cap concurrent trade page fetches (e.g. p-limit) for users with 10k+ trades to avoid DB connection spikes.
- Out-of-scope items from PR body (lazy widget-registry, route keep-alive) remain good next steps.
Sent by Cursor Automation: Coworker
| const userDataPromise = getUserData(); | ||
|
|
||
| const parallelStarted = performance.now(); | ||
| const [layoutResult, tradesResult, userDataResult] = await Promise.all([ |
There was a problem hiding this comment.
Nice — this is the core win. Auth stays serial; everything else becomes max(layout, trades, userData) instead of a sum. The timedPromise breakdown in logDashboardPerf will make before/after tuning easy in dev.
| // TODO: Cache layout client side (lightweight) | ||
| if (!dashboardLayout) { | ||
| const dashboardLayoutResponse = await getDashboardLayout(user.id); | ||
| const existingLayout = useUserStore.getState().dashboardLayout; |
There was a problem hiding this comment.
Skipping getDashboardLayout when the store already has a layout is a good micro-optimization for soft navigations / remounts. Using getState() avoids a stale closure on dashboardLayout.
| data.accounts || [] | ||
| // Metrics from trades already in hand — avoids a second trades DB fetch via server action. | ||
| const metricsStarted = performance.now(); | ||
| const accountsWithMetrics = computeMetricsForAccounts( |
There was a problem hiding this comment.
Correct equivalence move: calculateAccountMetricsAction already delegates to this same helper after fetching trades server-side. Reusing loadedTrades here drops a full duplicate DB round-trip.
| withLoading: false, | ||
| }), | ||
| ]); | ||
| // refreshUserDataOnly may finish before trades land; recompute from final stores. |
There was a problem hiding this comment.
Good race handling — refreshUserDataOnly may finish before trades land when run in parallel, so recomputing from final store snapshots here prevents briefly stale account metrics.
| const pageTrades = await getCachedTrades(userId, isSubscribed, page, chunkSize) | ||
| trades.push(...pageTrades) | ||
| } | ||
| const pageResults = await Promise.all( |
There was a problem hiding this comment.
Parallel pagination should cut wall time for large histories. Minor ops note: users with many pages will fan out concurrent Prisma/cache calls — consider a concurrency cap if you see pool pressure in prod.
| <div | ||
| ref={widgetRef} | ||
| className="relative h-full w-full rounded-lg bg-background shadow-[0_2px_4px_rgba(0,0,0,0.05)] group isolate animate-[fadeIn_1.5s_ease-in-out] overflow-clip" | ||
| className="relative h-full w-full rounded-lg bg-background shadow-[0_2px_4px_rgba(0,0,0,0.05)] group isolate animate-[fadeIn_0.2s_ease-out] overflow-clip" |
There was a problem hiding this comment.
1.5s → 0.2s is a big perceived-latency win once data is ready. ease-out reads snappier than the old ease-in-out for mount.


Why ~3.5s still happens (after #355 prefetch)
Prefetch fixed Connections → Dashboard route warmth. Remaining time is mostly cold / reload work:
loadDatawaterfall — auth → layout → trades → userData →calculateAccountMetricsAction(extra trades DB round-trip), with globalisLoading/ “Loading your trades…” until the endgetTradesActionfetched 1000-row chunks one after anotherfadeIn— even after data is ready, every widget animates for 1.5s (easy ~1.5s of perceived lag)widget-registryJS graph + soft-nav remount ofWidgetCanvasWhat this PR does
Promise.allfor layout + trades + userData after authcomputeMetricsForAccountsinstead of server actionisLoading)1.5s→0.2sIncludes the loadData work from #352 (supersedes that PR for the data path).
Out of scope (next)
widget-registry/ tab code-splitting (soft-nav remount JS cost)Test plan
bun run typecheck/dashboard: loading toast clears without waiting on Stripe; charts match prior metrics