From dbb1f29d37ce80fdd9307395acd479f691759af4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:41:50 +0000 Subject: [PATCH 1/3] perf: cut self-inflicted Cloud Run request volume Reduce origin (Cloud Run) request load that was driving the ~9x request spike: - Update-version checker (app layout): was fetching the full current page with `no-store` every 60s on every signed-in tab, forever. Now polls ~every 15 min, only while the tab is visible, with jitter so clients don't poll in lockstep, and stops once an update is detected. This was the single biggest driver of both the request spike and uncached CDN bandwidth. - Market page: the 60s refresh is a Server Action hitting the origin; throttled to 5 min (underlying market data revalidates on 60s+ anyway). - BTC price ticker: throttled from 60s to 5 min. - Wallet error-retry: replaced the fixed 3s retry with exponential backoff (3s/6s/12s, capped at 60s) and debounced the on-focus retry so a focus storm can't fire back-to-back origin calls. https://claude.ai/code/session_01PAJeRubmNYpCjy1PvvyfgJ --- src/app/(app)/layout.tsx | 41 +++++++++++++++++++++++++++++---- src/app/(app)/market/page.tsx | 6 ++++- src/contexts/wallet-context.tsx | 25 +++++++++++++++----- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index fd2bb57..dcd5c55 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -383,14 +383,37 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) { if (process.env.NODE_ENV === 'production' && window.__NEXT_DATA__) { const currentBuildId = window.__NEXT_DATA__.buildId; - const intervalId = setInterval(async () => { + // This fetch hits the App Hosting origin (Cloud Run) with `no-store`, + // so it is both a CDN miss and an origin request. Keep it infrequent, + // skip it while the tab is hidden, and add jitter so clients don't all + // poll in lockstep. + const BASE_INTERVAL = 15 * 60 * 1000; // 15 minutes + const JITTER = 60 * 1000; // up to 60s of spread + let timeoutId: ReturnType; + let stopped = false; + + const scheduleNext = () => { + const delay = BASE_INTERVAL + Math.floor(Math.random() * JITTER); + timeoutId = setTimeout(check, delay); + }; + + const check = async () => { + if (stopped) return; + + // Don't poll the origin while the tab is in the background. + if (document.visibilityState !== 'visible') { + scheduleNext(); + return; + } + try { const res = await fetch(window.location.href, { cache: 'no-store', }); - + if (!res.ok) { console.warn(`Update check failed with status: ${res.status}`); + scheduleNext(); return; } @@ -403,15 +426,23 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) { if (serverBuildId && currentBuildId && serverBuildId !== currentBuildId) { setIsUpdateAvailable(true); - clearInterval(intervalId); // Stop checking once an update is found. + stopped = true; // Stop checking once an update is found. + return; } } } catch (err) { console.warn('Failed to check for app update:', err); } - }, 60000); // Check every 60 seconds. - return () => clearInterval(intervalId); + scheduleNext(); + }; + + scheduleNext(); + + return () => { + stopped = true; + clearTimeout(timeoutId); + }; } }, []); diff --git a/src/app/(app)/market/page.tsx b/src/app/(app)/market/page.tsx index 75a00dd..ef9d7ae 100644 --- a/src/app/(app)/market/page.tsx +++ b/src/app/(app)/market/page.tsx @@ -144,11 +144,15 @@ export default function MarketPage() { }, [loadData]); useEffect(() => { + // Each refresh is a Server Action call to the App Hosting origin + // (Cloud Run). The underlying CoinGecko data revalidates on a 60s+ + // window, so a 5-minute client poll keeps the page fresh without + // hammering the origin. const interval = setInterval(() => { if (document.visibilityState === 'visible') { loadData(false); } - }, 60000); + }, 5 * 60 * 1000); return () => clearInterval(interval); }, [loadData]); diff --git a/src/contexts/wallet-context.tsx b/src/contexts/wallet-context.tsx index 38df73e..d671752 100644 --- a/src/contexts/wallet-context.tsx +++ b/src/contexts/wallet-context.tsx @@ -908,14 +908,27 @@ export const WalletProvider = ({ children, testXpub }: { children: ReactNode; te errorToastId.current = toastResult.id; - if (errorRetryTimeout.current) { - clearTimeout(errorRetryTimeout.current); - } - errorRetryTimeout.current = setTimeout(triggerRetry, 3000); + // Exponential backoff (3s, 6s, 12s ... capped at 60s) so a failing origin + // — wallet data is fetched via Server Actions on Cloud Run — is not hammered + // by retries. `immediate` is used when the user returns to the tab. + const scheduleRetry = (immediate = false) => { + if (errorRetryCount.current >= 3) return; + if (errorRetryTimeout.current) { + clearTimeout(errorRetryTimeout.current); + } + const delay = immediate + ? 1000 + : Math.min(3000 * 2 ** errorRetryCount.current, 60000); + errorRetryTimeout.current = setTimeout(triggerRetry, delay); + }; + + scheduleRetry(); + // Retry when the user returns to the tab, but route it through the same + // debounced timer so a focus storm can't fire back-to-back origin calls. const handleVisibility = () => { if (!document.hidden) { - triggerRetry(); + scheduleRetry(true); } }; @@ -958,7 +971,7 @@ export const WalletProvider = ({ children, testXpub }: { children: ReactNode; te logger.warn("Could not refresh BTC price:", e); } scheduleNext(); // Schedule the next execution - }, 60000); // every 60 seconds + }, 5 * 60 * 1000); // every 5 minutes }; scheduleNext(); From 4bfb387a5dcb9457787abaeb0da0816f944be0cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:41:50 +0000 Subject: [PATCH 2/3] perf: make public pages, static assets and app shells edge-cacheable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the Firebase App Hosting CDN cache-miss rate (uncached origin bandwidth jumped 52 GiB -> 726 GiB). App Hosting only edge-caches responses whose origin emits a cacheable Cache-Control header, and almost nothing did. next.config.ts headers(): - /public static assets (svg/png/jpg/webp/ico/woff/woff2): 1 week + SWR. - manifest.json: 1 day. - /landing and /about (public marketing pages): s-maxage 1h + SWR. - Signed-in app shells (dashboard, analysis, security, chat, report, coin-control): s-maxage 5m + SWR. These render an identical shell per user — wallet data loads client-side via Server Actions and auth gating is client-side — so only the static shell is cached; per-user data requests are separate POSTs that always reach the origin. Parameterised routes (address/[address] etc.) and /api/* are intentionally excluded. Also: - /landing and /about: add `revalidate = 3600` (ISR) to back the headers. - sitemap: use a stable lastModified instead of `new Date()` so it stops appearing "always new" to crawlers on every build. https://claude.ai/code/session_01PAJeRubmNYpCjy1PvvyfgJ --- next.config.ts | 48 ++++++++++++++++++++++++++++++++++++++++ src/app/about/page.tsx | 4 ++++ src/app/landing/page.tsx | 4 ++++ src/app/sitemap.ts | 20 ++++++++++------- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/next.config.ts b/next.config.ts index ba078a1..02e0c3b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -92,6 +92,54 @@ const nextConfig: NextConfig = { }, ], }, + { + // Static assets served from /public (images, icons, fonts). Filenames + // are not content-hashed, so use a week-long TTL with revalidation + // rather than `immutable`. + source: '/:path*.(svg|png|jpg|jpeg|gif|webp|avif|ico|woff|woff2)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=604800, stale-while-revalidate=86400', + }, + ], + }, + { + source: '/manifest.json', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=86400', + }, + ], + }, + { + // Public, non-personalised marketing pages. Edge-cache them but allow + // an instant revalidate after each deploy. + source: '/(landing|about)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400', + }, + ], + }, + { + // Signed-in app *shells*. These pages render an identical HTML shell for + // every user — all wallet data loads client-side via Server Actions, and + // auth gating happens client-side from localStorage. The shells are + // therefore safe to edge-cache (short TTL + SWR). NOTE: only the static + // shell is cached; the per-user data requests are separate POSTs that + // always reach the origin. Parameterised routes (address/[address] etc.) + // are intentionally excluded. + source: '/(dashboard|analysis|security|chat|report|coin-control)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=0, s-maxage=300, stale-while-revalidate=3600', + }, + ], + }, ]; }, // Updated experimental flags for Next.js 16 diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 92c7f85..d696756 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -20,6 +20,10 @@ import { Lightbulb } from 'lucide-react'; +// Public marketing page — statically generated and revalidated hourly so it +// can be served from the Firebase App Hosting CDN edge. +export const revalidate = 3600; + export const metadata: Metadata = { title: 'About BitSleuth - AI Bitcoin Wallet Analyzer | Security & Privacy Tool', description: 'Learn about BitSleuth, the leading AI-powered Bitcoin wallet analyzer. Discover how our advanced security analysis, privacy tools, and transaction insights help Bitcoin users worldwide.', diff --git a/src/app/landing/page.tsx b/src/app/landing/page.tsx index 7b6278d..d6ba39d 100644 --- a/src/app/landing/page.tsx +++ b/src/app/landing/page.tsx @@ -20,6 +20,10 @@ import { Globe } from 'lucide-react'; +// Public marketing page — statically generated and revalidated hourly so it +// can be served from the Firebase App Hosting CDN edge. +export const revalidate = 3600; + export const metadata: Metadata = { title: 'BitSleuth - Free AI Bitcoin Wallet Analyzer & Security Tool', description: 'The most advanced AI-powered Bitcoin wallet analyzer. Get comprehensive security insights, privacy analysis, and transaction patterns for any Bitcoin wallet. Free Bitcoin wallet scanner with AI technology.', diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 50b3735..de645ee 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,49 +1,53 @@ import { type MetadataRoute } from 'next' +// Stable timestamp so the sitemap doesn't appear "always new" to crawlers on +// every build/request. Bump when public page content changes meaningfully. +const LAST_MODIFIED = new Date('2026-06-01T00:00:00Z'); + export default function sitemap(): MetadataRoute.Sitemap { const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://app.bitsleuth.ai'; - + const staticRoutes = [ { url: `${siteUrl}/`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'daily' as const, priority: 1.0, }, { url: `${siteUrl}/market`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'hourly' as const, priority: 0.9, }, { url: `${siteUrl}/mempool`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'hourly' as const, priority: 0.9, }, { url: `${siteUrl}/discover`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'daily' as const, priority: 0.8, }, // Add more public pages that can be indexed { url: `${siteUrl}/landing`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'weekly' as const, priority: 0.9, }, { url: `${siteUrl}/about`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'monthly' as const, priority: 0.8, }, { url: `${siteUrl}/feedback`, - lastModified: new Date(), + lastModified: LAST_MODIFIED, changeFrequency: 'weekly' as const, priority: 0.6, }, From fed7174e5d3b19f79ef8a6eeb9cc27a797647197 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 21:12:30 +0000 Subject: [PATCH 3/3] fix: guard update-checker state update against post-unmount Code-review follow-up. In the version-update checker, if the `fetch(window.location.href)` is still in flight when the effect is cleaned up (component unmounted), the resolved callback could call `setIsUpdateAvailable` on an unmounted component. The cleanup already sets the shared `stopped` flag, so check it before the state update. https://claude.ai/code/session_01PAJeRubmNYpCjy1PvvyfgJ --- src/app/(app)/layout.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index dcd5c55..6f10d35 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -425,6 +425,10 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) { const serverBuildId = serverData.buildId; if (serverBuildId && currentBuildId && serverBuildId !== currentBuildId) { + // If the effect was cleaned up (unmounted) while this fetch was + // in flight, `stopped` is already true — skip the state update + // to avoid setting state on an unmounted component. + if (stopped) return; setIsUpdateAvailable(true); stopped = true; // Stop checking once an update is found. return;