perf: bound SWR cache, clean stale storage, cap annotations

Add LRU cache provider (200 entries) to prevent unbounded SWR memory
growth. Clean up stale PKCE localStorage keys on app init. Cap chart
annotations to 20 visible reference lines with overflow indicator.
This commit is contained in:
Usman Baig
2026-03-10 21:19:33 +01:00
parent 502f4952fc
commit 205cdf314c
7 changed files with 99 additions and 8 deletions

View File

@@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem.
- **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data.
- **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices.
- **Real-time updates work across all servers.** If Pulse runs on multiple servers behind a load balancer, real-time visitor updates now stay in sync no matter which server you're connected to. Previously, you might miss live visitor changes if your connection landed on a different server than the one fetching data.
- **Lighter memory usage in long sessions.** If you manage many sites and keep Pulse open for hours, the app now automatically clears out old cached data for sites you're no longer viewing. This keeps the tab responsive and prevents it from slowly using more and more memory over time.
- **Cleaner login storage.** Temporary data left behind by abandoned sign-in attempts is now cleaned up automatically when the app loads. This prevents clutter from building up in your browser's storage over time.
- **Tidier annotation display.** If you've added a lot of annotations to your chart, only the 20 most recent are shown as lines on the chart to keep it readable. A "+N more" label lets you know there are additional annotations.
- **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time.
- **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time.
- **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes.

View File

@@ -1,5 +1,6 @@
import { ThemeProviders, Toaster } from '@ciphera-net/ui'
import { AuthProvider } from '@/lib/auth/context'
import SWRProvider from '@/components/SWRProvider'
import type { Metadata, Viewport } from 'next'
import { Plus_Jakarta_Sans } from 'next/font/google'
import LayoutContent from './layout-content'
@@ -46,12 +47,14 @@ export default function RootLayout({
return (
<html lang="en" className={plusJakartaSans.variable} suppressHydrationWarning>
<body className="antialiased min-h-screen flex flex-col bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-50">
<ThemeProviders>
<AuthProvider>
<LayoutContent>{children}</LayoutContent>
<Toaster />
</AuthProvider>
</ThemeProviders>
<SWRProvider>
<ThemeProviders>
<AuthProvider>
<LayoutContent>{children}</LayoutContent>
<Toaster />
</AuthProvider>
</ThemeProviders>
</SWRProvider>
</body>
</html>
)

View File

@@ -0,0 +1,12 @@
'use client'
import { SWRConfig } from 'swr'
import { boundedCacheProvider } from '@/lib/swr/cache-provider'
export default function SWRProvider({ children }: { children: React.ReactNode }) {
return (
<SWRConfig value={{ provider: boundedCacheProvider }}>
{children}
</SWRConfig>
)
}

View File

@@ -19,6 +19,8 @@ const ANNOTATION_COLORS: Record<string, string> = {
other: '#a3a3a3',
}
const MAX_VISIBLE_ANNOTATIONS = 20
const ANNOTATION_LABELS: Record<string, string> = {
deploy: 'Deploy',
campaign: 'Campaign',
@@ -254,6 +256,9 @@ export default function Chart({
return markers
}, [annotations, chartData])
const visibleAnnotationMarkers = annotationMarkers.slice(0, MAX_VISIBLE_ANNOTATIONS)
const hiddenAnnotationCount = Math.max(0, annotationMarkers.length - MAX_VISIBLE_ANNOTATIONS)
// ─── Right-click handler ──────────────────────────────────────────
const handleChartContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!canManageAnnotations) return
@@ -490,7 +495,7 @@ export default function Chart({
/>
{/* Annotation reference lines */}
{annotationMarkers.map((marker) => {
{visibleAnnotationMarkers.map((marker) => {
const primaryCategory = marker.annotations[0].category
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
return (
@@ -534,7 +539,7 @@ export default function Chart({
{annotationMarkers.length > 0 && (
<>
<span className="text-[10px] font-medium text-neutral-400 dark:text-neutral-500 mr-1">Annotations:</span>
{annotationMarkers.map((marker) => {
{visibleAnnotationMarkers.map((marker) => {
const primary = marker.annotations[0]
const color = ANNOTATION_COLORS[primary.category] || ANNOTATION_COLORS.other
const count = marker.annotations.length
@@ -577,6 +582,11 @@ export default function Chart({
</button>
)
})}
{hiddenAnnotationCount > 0 && (
<span className="text-[10px] text-neutral-400 dark:text-neutral-500 ml-1">
+{hiddenAnnotationCount} more
</span>
)}
</>
)}
</div>

View File

@@ -7,6 +7,7 @@ import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-n
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { logger } from '@/lib/utils/logger'
import { cleanupStaleStorage } from '@/lib/utils/storage-cleanup'
interface User {
id: string
@@ -131,6 +132,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Initial load
useEffect(() => {
const init = async () => {
cleanupStaleStorage()
// * 1. Check server-side session (cookies)
let session: Awaited<ReturnType<typeof getSessionAction>> = null
try {

42
lib/swr/cache-provider.ts Normal file
View File

@@ -0,0 +1,42 @@
// * Bounded LRU cache provider for SWR
// * Prevents unbounded memory growth during long sessions across many sites
const MAX_CACHE_ENTRIES = 200
export function boundedCacheProvider() {
const map = new Map()
const accessOrder: string[] = []
const touch = (key: string) => {
const idx = accessOrder.indexOf(key)
if (idx > -1) accessOrder.splice(idx, 1)
accessOrder.push(key)
}
const evict = () => {
while (map.size > MAX_CACHE_ENTRIES && accessOrder.length > 0) {
const oldest = accessOrder.shift()!
map.delete(oldest)
}
}
return {
get(key: string) {
if (map.has(key)) touch(key)
return map.get(key)
},
set(key: string, value: any) {
map.set(key, value)
touch(key)
evict()
},
delete(key: string) {
map.delete(key)
const idx = accessOrder.indexOf(key)
if (idx > -1) accessOrder.splice(idx, 1)
},
keys() {
return map.keys()
},
}
}

View File

@@ -0,0 +1,17 @@
// * Cleans up stale localStorage entries on app initialization
// * Prevents accumulation from abandoned OAuth flows
export function cleanupStaleStorage() {
if (typeof window === 'undefined') return
try {
// * PKCE keys are only needed during the OAuth callback
// * If we're not on the callback page, they're stale leftovers
if (!window.location.pathname.includes('/auth/callback')) {
localStorage.removeItem('oauth_state')
localStorage.removeItem('oauth_code_verifier')
}
} catch {
// * Ignore errors (private browsing, storage disabled, etc.)
}
}